Metadata-Version: 2.4
Name: aa-discord-audit
Version: 0.1.0a6
Summary: Reconciliation audit for Alliance Auth's Discord integration.
Keywords: allianceauth,discord,audit,reconciliation
Author: Boris Talovikov
Author-email: Boris Talovikov <boris.t.66@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.2
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Chat
Classifier: Topic :: System :: Systems Administration
Requires-Dist: allianceauth>=4,<6
Requires-Dist: django-prometheus>=2.3,<3 ; extra == 'metrics'
Maintainer: Boris Talovikov
Maintainer-email: Boris Talovikov <boris.t.66@gmail.com>
Requires-Python: >=3.10, <3.14
Project-URL: Source, https://gitlab.com/eveo7/aa-discord-audit
Project-URL: Tracker, https://gitlab.com/eveo7/aa-discord-audit/-/issues
Provides-Extra: metrics
Description-Content-Type: text/markdown

# aa-discord-audit

Reconciliation audit for Alliance Auth's Discord integration. Compares
the actual role assignments in the configured Discord guild against
the state expressed in Alliance Auth (Groups + State per user) and
closes the gap through which moderators can hand-assign AA-named roles
to users AA does not know about.

## Safety posture

The audit is **safe by default**:

- The first run after install is locked to dry-run regardless of
  operator settings. Releasing the lock requires an explicit
  `InitialAuditAcknowledgement` (admin or shell only).
- The default policy is `report` for every category — destructive
  actions are opt-in.
- Audit-trail rows (`AuditRun`, `AuditFinding`, `AuditInvocation`,
  `ConfigChangeLog`) are append-only at the manager and instance
  layers; bulk `update()` / `bulk_update()` are blocked.
- Webhook URLs are treated as credentials and redacted from logs,
  exception messages, and persisted argv.

## How it works

Each guild member is classified into one category:

| Category         | Meaning                                                |
|------------------|--------------------------------------------------------|
| `unknown_guest`  | Discord member AA knows nothing about                  |
| `linked_no_perm` | Identity known to AA but lacks `discord.access_discord`|
| `bot_filtered`   | Configured bot account — never acted upon              |

The operator maps each category to one action:

| Action       | Behaviour                                       |
|--------------|-------------------------------------------------|
| `report`     | Record the finding; no Discord-side change      |
| `strip`      | Remove AA-managed roles                         |
| `strip_kick` | Remove AA-managed roles, then kick from guild   |

The mapping is the `AA_DISCORD_AUDIT_POLICY` setting; per-group and
per-state overrides nest inside each category.

## Installation

```sh
pip install aa-discord-audit
```

In your Auth `local.py`:

```python
# `aa_discord_audit` must appear AFTER
# `allianceauth.services.modules.discord` so the discord module's
# models load first; `apps.ready()` raises `ImproperlyConfigured`
# otherwise.
INSTALLED_APPS += ["aa_discord_audit"]

MIDDLEWARE += [
    "aa_discord_audit.current_user.CurrentUserMiddleware",
]
```

Then run migrations:

```sh
python manage.py migrate aa_discord_audit
```

The `CurrentUserMiddleware` is mandatory — `apps.ready()` raises
`ImproperlyConfigured` if it is missing. It is what lets the
`ConfigChangeLog` signal handler attribute admin edits to a real user
instead of `<system>`.

## Quick start

1. Grant `aa_discord_audit.run_audit` to the operator role that runs
   audits.
2. Run a dry-run audit:

   ```sh
   python manage.py audit_discord_roles --action report
   ```

3. Review findings under **Discord Audit → Audit runs** in the
   Auth dashboard.
4. Release the first-run lock — either create an
   `InitialAuditAcknowledgement` row through the admin, or run
   `python manage.py audit_acknowledge_initial`. Both require
   `aa_discord_audit.run_audit` **and**
   `aa_discord_audit.acknowledge_initial_audit`.
5. Re-run with the destructive action of your choice when ready.

## Permissions

| Codename                                          | Gates                                          |
|---------------------------------------------------|------------------------------------------------|
| `aa_discord_audit.run_audit`                      | management command, beat task, run delete     |
| `aa_discord_audit.acknowledge_initial_audit`      | release the first-run dry-run lock            |
| `aa_discord_audit.manage_discord_identity`        | `DiscordIdentity` admin                       |
| `aa_discord_audit.manage_role_exception`          | `ManagedRoleException` admin                  |
| `aa_discord_audit.manage_protected_member`        | `ProtectedDiscordMember` admin                |
| `aa_discord_audit.manage_bot_account_uid`         | `BotAccountUid` admin                         |
| `aa_discord_audit.manage_finding_override`        | `FindingActionOverride` admin                 |
| `aa_discord_audit.view_auditrun` *(and friends)*  | read-only audit-trail in the Auth dashboard   |

The `manage_*` codenames are split per blast radius so a junior with
`manage_bot_account_uid` cannot also defang the audit by editing
`ManagedRoleException`.

## Settings

All settings are optional. Defaults are safe.

```python
# Action policy. Bare-string form below is shorthand for
# {"default": "<action>"}; use the nested form for per-group / per-state
# overrides keyed by AA group name and state name.
AA_DISCORD_AUDIT_POLICY = {
    "unknown_guest":  "report",
    "linked_no_perm": "report",
    # "linked_no_perm": {
    #     "default":  "strip",
    #     "by_state": {"Guest": "report"},
    #     "by_group": {"Directors": "report"},
    # },
}

# AA-notify fan-out to permission holders.
AA_DISCORD_AUDIT_NOTIFY_ADMINS = True

# Discord webhook for run summaries. Treat as a credential.
AA_DISCORD_AUDIT_WEBHOOK_URL = None

# uids skipped as bot accounts (in addition to the BotAccountUid admin
# table).
AA_DISCORD_AUDIT_BOT_UIDS = []

# Auto-discover bot accounts by Discord nickname heuristics. Reserved
# for v2; not implemented in the MVP. The startup validator raises
# ImproperlyConfigured if set to True — keep this False and use the
# explicit BotAccountUid admin table instead.
AA_DISCORD_AUDIT_AUTO_DISCOVER_BY_NICKNAME = False

# Retention. 0 disables pruning; the validator refuses 0 unless the
# acknowledged flag below is also set.
AA_DISCORD_AUDIT_RUN_RETENTION_DAYS = 180
AA_DISCORD_AUDIT_RETENTION_OPT_OUT_ACKNOWLEDGED = False

# Idempotency-key TTL. When positive, prune_audit_runs releases
# AuditRun.idempotency_key on rows older than the cutoff while the
# row itself stays for RUN_RETENTION_DAYS. 0 (default) disables the
# expiry; the key dies with the row.
AA_DISCORD_AUDIT_IDEMPOTENCY_KEY_TTL_DAYS = 0

# Per-run deadline. The Celery task's soft_time_limit follows.
AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES = 60

# Rolling 24h rate limit on accepted audit triggers per user.
# DISABLED is an opt-out gate; refuses to take effect unless
# explicitly toggled.
AA_DISCORD_AUDIT_RUN_RATE_LIMIT_PER_DAY = 5
AA_DISCORD_AUDIT_RUN_RATE_LIMIT_DISABLED = False

# Discord webhook delivery tuning.
AA_DISCORD_AUDIT_WEBHOOK_TIMEOUT = 10
AA_DISCORD_AUDIT_WEBHOOK_MAX_RETRIES = 3

# Opt-in: bulk PATCH role strip. Faster on large guilds; off by default
# while we collect operator feedback on Discord-side rate-limit shape.
AA_DISCORD_AUDIT_USE_BULK_ROLE_STRIP = False

# Periodic (Celery beat) audit. Destructive actions on the unattended
# beat require this explicit opt-in, independent of the one-shot
# first-run lock: a beat run carrying strip/kick is coerced to report
# otherwise, with a startup warning on mismatch.
AA_DISCORD_AUDIT_BEAT_ALLOW_DESTRUCTIVE = False

# Coarse global floor between beat runs, in minutes. 0 (default)
# disables it — the Celery beat schedule is the primary cadence
# control. A backstop against a misconfigured fast schedule
# (QueueOnce only dedupes concurrent ticks, not back-to-back ones).
AA_DISCORD_AUDIT_BEAT_MIN_INTERVAL_MINUTES = 0

# Pin the CLI acting user to a Django username (the "service principal"
# pattern). The management command has no HTTP request, so the actor is
# normally derived from the OS login; set this for clean, stable
# ConfigChangeLog attribution from cron and manual CLI alike. Resolved
# and permission-checked per invocation.
AA_DISCORD_AUDIT_CLI_ACTOR = None
```

## Management commands

| Command                  | Purpose                                                          |
|--------------------------|------------------------------------------------------------------|
| `audit_discord_roles`    | Primary entry point. `--action {report,strip,strip_kick}`.       |
| `audit_discord_roles --resume <run_id>`        | Re-walk PENDING findings of an existing run.       |
| `audit_discord_roles --abandon <run_id>`       | Mark a stuck run as ABANDONED.                     |
| `audit_discord_roles --diff <run_id>`          | Compare current state to a historical run.         |
| `audit_discord_roles --explain <member_id>`    | Per-member classification (read-only).             |
| `audit_discord_roles --policy-preview <json>`  | Project a hypothetical policy.                     |
| `audit_discord_roles --from-fixture <path>`    | Replay against a JSON snapshot.                    |
| `audit_acknowledge_initial` | Release the first-run dry-run lock from the console.          |
| `audit_benchmark`        | Synthetic-load sizing benchmark (see [`docs/performance.md`](docs/performance.md)). |
| `prune_audit_runs`       | Retention pruning.                                               |

A long-running ``audit_discord_roles`` reacts cleanly to ``SIGTERM``
and ``SIGINT``: the run is flipped to ``INTERRUPTED`` and the apply
loop drops out at the next finding boundary so partial work is
durable in the audit trail. ``audit_discord_roles --resume <run_id>``
picks the run up from the remaining ``PENDING`` findings.

## Periodic audit (Celery beat)

`audit_orphan_members` runs the same build + apply pipeline on a
schedule you wire into Celery beat (it is not scheduled by default).
The unattended path is gated for safety:

- **Report-only unless armed.** A beat run is coerced to `report`
  regardless of policy unless `AA_DISCORD_AUDIT_BEAT_ALLOW_DESTRUCTIVE`
  is set — releasing the one-shot first-run lock for a manual CLI run
  does not arm the beat. A boot-time warning fires when the policy is
  destructive but the beat is not opted in.
- **Attributed.** Every beat run writes an accepted system-actor
  `AuditInvocation` (`triggered_by=BEAT`), so the audit-the-auditor
  trail covers unattended runs too.
- **Self-healing.** A run stranded by a soft-time-limit hit or a worker
  crash is reconciled to the resumable `INTERRUPTED` state on the next
  tick. `AA_DISCORD_AUDIT_BEAT_MIN_INTERVAL_MINUTES` is a coarse floor
  against a misconfigured fast schedule.

## Operator dashboard

Mounted under the Auth main nav as **Discord Audit**. Read-only views,
with coloured action / state badges and sortable, filterable DataTables
throughout:

- **Audit runs** — a summary card with fleet-wide audit posture above a
  badged, sortable list; per-run detail splits into cards around the
  per-finding table.
- **Per-finding explain** — classification, resolved action (with the
  resolved policy as JSON and an as-of marker), and which override
  layer (per-uid override, per-group / per-state policy,
  `ProtectedDiscordMember`, first-run lock) decided the outcome.
- **Invocations** — every CLI / beat trigger including refused
  attempts, with the run state surfaced and the full argv expandable.
  Surfaces rate-limit usage.
- **Config change log** — every operator edit to the operator-managed
  config tables, rendered as a field-level before/after diff for
  updates (pretty-printed JSON for create / delete) with a
  case-insensitive actor filter. The audit-the-auditor trail.

## Observability

Optional Prometheus instrumentation behind the `[metrics]` extra. The
module ships a cooperate-don't-depend layer: when `django-prometheus`
is installed the audit's counters and histograms register into the
default `prometheus_client.REGISTRY` and `django-prometheus`'
`/metrics` view exports them alongside its own series. Without the
extra every metric reference resolves to a no-op stub at near-zero
runtime cost — the call-sites stay identical.

```sh
pip install aa-discord-audit[metrics]
```

Metric catalog, label vocabulary, and Grafana recipes live in
[`docs/METRICS.md`](docs/METRICS.md) (Russian translation:
[`docs/METRICS.ru.md`](docs/METRICS.ru.md)).

## Documentation

- [`docs/runbook.md`](docs/runbook.md) — operator runbook: bot Discord
  permissions, pre-flight checklist, releasing the first-run lock,
  incident playbooks, diagnostic toggles.
- [`docs/performance.md`](docs/performance.md) — `audit_benchmark`
  reference numbers and sizing implications.
- [`docs/METRICS.md`](docs/METRICS.md) /
  [`docs/METRICS.ru.md`](docs/METRICS.ru.md) — Prometheus metric
  catalog, label vocabulary, Grafana recipes.

## Development

```sh
make dev         # uv sync --all-groups + pre-commit install
make test        # uv run nox -s tests
make lint        # uv run nox -s lint
make typecheck   # mypy + basedpyright
make coverage    # term + html + xml report
make package     # uv build
```

Toolchain is uv-only. Line length is 79 (Python) / 120 (Markdown).

## Requirements

- Python 3.10–3.13
- Django 4.2 or 5.2
- Alliance Auth 4.x or 5.x

## Translations

- [Русский](README.ru.md)

## License

MIT — see [`LICENSE`](LICENSE).
