# 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
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 through the admin: create an
   `InitialAuditAcknowledgement` row (requires
   `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. Off by
# default: the explicit BotAccountUid table is the recommended path.
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 = 6
AA_DISCORD_AUDIT_RUN_RATE_LIMIT_DISABLED = False

# Discord webhook delivery tuning.
AA_DISCORD_AUDIT_WEBHOOK_TIMEOUT = 5
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
```

## 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_benchmark`        | Synthetic-load sizing benchmark (see [`docs/performance.md`](https://gitlab.com/eveo7/aa-discord-audit/-/blob/main/docs/performance.md)). |
| `prune_audit_runs`       | Retention pruning.                                               |

## Operator dashboard

Mounted under the Auth main nav as **Discord Audit**. Read-only views:

- **Audit runs** — list + per-run detail with the per-finding table.
- **Per-finding explain** — classification, resolved action, 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. Surfaces rate-limit usage.
- **Config change log** — every operator edit to the operator-managed
  config tables. 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`](https://gitlab.com/eveo7/aa-discord-audit/-/blob/main/docs/METRICS.md).

## Documentation

- [`docs/runbook.md`](https://gitlab.com/eveo7/aa-discord-audit/-/blob/main/docs/runbook.md) — operator runbook: bot Discord
  permissions, pre-flight checklist, releasing the first-run lock,
  incident playbooks, diagnostic toggles.
- [`docs/performance.md`](https://gitlab.com/eveo7/aa-discord-audit/-/blob/main/docs/performance.md) — `audit_benchmark`
  reference numbers and sizing implications.
- [`docs/METRICS.md`](https://gitlab.com/eveo7/aa-discord-audit/-/blob/main/docs/METRICS.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
- Alliance Auth 4.x

## Translations

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

## License

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