Metadata-Version: 2.4
Name: aa-discord-audit
Version: 0.1.0a16
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 :: 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>=5,<6
Requires-Dist: typing-extensions>=4.10
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

[![PyPI version][pypi-badge]][pypi-url]
[![Supported Python versions][python-badge]][pypi-url]
[![License: MIT][license-badge]][license-url]

[pypi-badge]: https://img.shields.io/pypi/v/aa-discord-audit.svg
[python-badge]: https://img.shields.io/pypi/pyversions/aa-discord-audit.svg
[license-badge]: https://img.shields.io/pypi/l/aa-discord-audit.svg
[pypi-url]: https://pypi.org/project/aa-discord-audit/
[license-url]: https://gitlab.com/eveo7/aa-discord-audit/-/blob/main/LICENSE

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.

> **Status:** alpha (`0.1.x`). The public API and settings may still
> change before `1.0`.

## Contents

- [Safety posture](#safety-posture)
- [How it works](#how-it-works)
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Permissions](#permissions)
- [Settings](#settings)
- [Management commands](#management-commands)
- [Periodic audit (Celery beat)](#periodic-audit-celery-beat)
- [Celery tasks](#celery-tasks)
- [Operator dashboard](#operator-dashboard)
- [Observability](#observability)
- [Limitations](#limitations)
- [Documentation](#documentation)
- [Development](#development)
- [Merge request CI](#merge-request-ci)

## 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.

## Requirements

- Python 3.10–3.13
- Django 5.2
- Alliance Auth 5.x
- Alliance Auth's Discord service module
  (`allianceauth.services.modules.discord`) installed and configured
  (bot token + guild)

## 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.run_audit_destructive`          | web-launch gate for `strip` / `strip_kick` (separate from `run_audit`) |
| `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",
    # "strip" is rejected at startup for linked_no_perm (it conflicts with
    # AA's update_groups task); use "strip_kick" instead.
    # "linked_no_perm": {
    #     "default":  "strip_kick",
    #     "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. Sizing
# against the Discord rate limit: see "Run deadline and the Discord
# rate limit" below.
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

# Guild presence sampling. Requires the [metrics] extra and
# aa-discord-audit's own /audit/discord/metrics scrape target.
# PRESENCE_ENABLED is independent of [metrics]: even without
# the extra, disabling it suppresses the background sampler task.
AA_DISCORD_AUDIT_PRESENCE_ENABLED = True

# Desired presence sample cadence in minutes. Values below 5 fall
# back to the default of 10.
AA_DISCORD_AUDIT_PRESENCE_SAMPLE_INTERVAL_MINUTES = 10

# Gate for the members_by_group aggregate gauge. Disable if the
# number of AA groups causes unacceptable cardinality.
AA_DISCORD_AUDIT_METRICS_MEMBERS_BY_GROUP = True

# Gate for the members_by_role aggregate gauge. Off by default —
# Discord guilds can have hundreds of roles; enable only after
# reviewing role count to avoid a cardinality explosion.
AA_DISCORD_AUDIT_METRICS_MEMBERS_BY_ROLE = False
```

### Run deadline and the Discord rate limit

Alliance Auth's Discord client allows 5 requests per about 5.5 s for
all AA processes together, about 0.9 requests per second shared with
AA's own Discord sync tasks. When the limit is used up, the audit
waits for it to reset and retries the request; AA's sync tasks retry
later on their own.

- Applying a finding costs 1 GET plus 1 DELETE per removed role. With
  `AA_DISCORD_AUDIT_USE_BULK_ROLE_STRIP = True` it is 1 GET plus 1
  PATCH when the member keeps at least one role.
- Building the plan reads 1000 members per request: a 50,000-member
  guild takes 50 requests, about 1 minute.
- Estimate a run as requests / 0.9 seconds: 3000 findings with two
  removed roles each are about 9000 requests, or 2 h 45 min.

A request is sent only if it can finish before the deadline, so the
last one starts at least 35 s (AA's connect + read timeouts) before
it. A run that does not fit stops with `BUDGET_EXCEEDED` and leaves the
remaining findings PENDING. `--resume` refuses that state: start a new
run, which rebuilds the plan and no longer sees members whose roles
were already removed. To cover a large guild in one run, raise
`AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES` or enable bulk strip; bulk
strip writes the role list read before the wait, so a role granted
during a wait is removed too.

## 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`](https://gitlab.com/eveo7/aa-discord-audit/-/blob/main/docs/performance.md)). |
| `prune_audit_runs`       | Retention pruning.                                               |
| `audit_abandon_stuck_runs` | Reconcile stuck PENDING runs leaked by a crashed worker — flips them to ABANDONED (see the runbook; a stuck RUNNING run uses `audit_discord_roles --abandon`). |

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.

## Celery tasks

The package registers five tasks under the `aa_discord_audit.*` name
prefix. Only the beat tasks need a schedule; the rest are event-driven
or run on demand. None are scheduled for you.

| Task | How it runs | What it does |
|------|-------------|--------------|
| `aa_discord_audit.audit_orphan_members` | Celery beat — you schedule it | The unattended build + apply audit described above. Report-only unless armed; shares the `discord.user_actions.<uid>` lock with AA's `update_groups`. |
| `aa_discord_audit.retry_pending_kicks` | Celery beat — you schedule it | Bounded sweep that re-dispatches kicks deferred by a transient Discord failure, honouring a per-row cooldown so a hard-failing member is not hammered. |
| `aa_discord_audit.process_pending_run` | Event-driven — enqueued by a web launch | Picks up the `PENDING` run a web launch created, flips it to `RUNNING`, and drives the pipeline against the run's frozen confirmation flag. |
| `aa_discord_audit.prune_audit_runs` | Celery beat / cron — you schedule it (also a management command) | Retention: idempotency-key expiry then row deletion (see **Management commands** and the runbook). |
| `aa_discord_audit.sample_guild_presence` | Celery beat — you schedule it | Light, REST-only sample of guild member / online / boost counts into the latest-only presence snapshot for the Prometheus presence gauges. Gated on `AA_DISCORD_AUDIT_PRESENCE_ENABLED`; a skip-guard bounds a misconfigured fast schedule (floor `AA_DISCORD_AUDIT_PRESENCE_SAMPLE_INTERVAL_MINUTES`, default 10). Inert until both the `[metrics]` extra and a beat entry are present (see [`docs/METRICS.md`](docs/METRICS.md)). |

To schedule the beat tasks, add them to `CELERYBEAT_SCHEDULE` in your
`local.py`, for example:

```python
CELERYBEAT_SCHEDULE["aa_discord_audit_sample_guild_presence"] = {
    "task": "aa_discord_audit.sample_guild_presence",
    "schedule": 600,  # seconds; honoured no finer than the sampler floor
}
```

## Operator dashboard

Mounted under the Auth main nav as **Discord Audit**. Read-only views;
each list carries a server-side search box that matches across every
row — not just the page in view — alongside its dropdown filters:

- **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.

### Launching an audit from the web UI

Operators with the **`aa_discord_audit.run_audit`** permission see a
**Launch Audit** button on the *Audit runs* page. Clicking it opens a
Bootstrap modal that surfaces the configured policy mode (report-only
or destructive), the initial-acknowledgement state, and a *Confirm
Launch* button. Submitting POSTs to `/run-launch/`, which creates a
PENDING `AuditRun`, enqueues `process_pending_run` via Celery, and
redirects to the run-detail page.

The web path mirrors the CLI's safety gates:

- **First-run lock.** If `audit_acknowledge_initial` has not been
  run, the modal shows a refusal block (no submit button) instead of
  the *Confirm Launch* action. A POST that bypasses the modal (e.g.
  curl) is refused server-side, the refusal is recorded in
  `AuditInvocation`, and the operator is redirected with a flash
  message.
- **Destructive intent.** Whether a launch may strip roles or kick
  members is decided server-side from the operator's permissions and
  the policy: `was_confirmation_bypassed = user.has_perm(run_audit_destructive)
  and policy_has_destructive(policy)`. The UI has **no toggle** — the
  destructive permission alone determines intent. A `run_audit`-only
  operator who triggers a launch against a destructive policy gets a
  silent REPORT-only run (the same coercion the CLI applies without
  `--yes`).
- **Per-user rate limit.** The same daily quota as the CLI; the modal
  still opens but the POST returns a flash + redirect once the quota
  is spent.

While a run is in a non-terminal state (`PENDING`, `RUNNING`, or
`INTERRUPTED`), the run-detail page polls `/runs/<pk>/state.json`
every five seconds and updates the State card in place. Polling
pauses on hidden tabs and stops as soon as the run reaches a terminal
state.

## Observability

Optional Prometheus instrumentation behind the `[metrics]` extra —
absent, every metric call resolves to a no-op stub at near-zero cost.
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.

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

The snapshot gauges — guild presence and the per-group / per-role
membership aggregates — take a second path: they live in a dedicated
registry exported by the module's own `/audit/discord/metrics`
endpoint. django-prometheus' multiprocess collector reads only mmap
files and skips custom collectors, so these gauges need their own
scrape target. Like any `/metrics` target it is unauthenticated —
restrict it at the reverse-proxy or network layer. Both surfaces stay
inert without the `[metrics]` extra.

Metric catalog, label vocabulary, and Grafana recipes live in
[`docs/METRICS.md`](https://gitlab.com/eveo7/aa-discord-audit/-/blob/main/docs/METRICS.md)
(Russian translation:
[`docs/METRICS.ru.md`](https://gitlab.com/eveo7/aa-discord-audit/-/blob/main/docs/METRICS.ru.md)).

## Limitations

- **Single guild.** The audit reconciles the one Discord guild AA is
  configured against; it does not span multiple guilds.
- **AA-managed roles only.** Strip / kick act on AA-named roles and
  guild membership; roles AA does not manage are never touched.
- **No nickname auto-discovery.** Bot accounts are recognised only via
  the explicit `BotAccountUid` table / `AA_DISCORD_AUDIT_BOT_UIDS` —
  `AA_DISCORD_AUDIT_AUTO_DISCOVER_BY_NICKNAME` is reserved for v2 and
  not implemented.
- **Alpha surface.** Settings names and the public API may change
  before `1.0`.

## 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)
  / [`docs/METRICS.ru.md`](https://gitlab.com/eveo7/aa-discord-audit/-/blob/main/docs/METRICS.ru.md)
  — Prometheus metric catalog, label vocabulary, Grafana recipes.
- Source code: <https://gitlab.com/eveo7/aa-discord-audit>
- Issue tracker: <https://gitlab.com/eveo7/aa-discord-audit/-/issues>
- Changelog:
  [`CHANGELOG.md`](https://gitlab.com/eveo7/aa-discord-audit/-/blob/main/CHANGELOG.md)

## Development

```sh
make dev         # uv sync --all-groups + pre-commit install
make tests       # 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).

## Merge request CI

This project follows the group CI gate described in
[renovate-runner: CI gate for group projects](https://gitlab.com/eveo7/renovate-runner#ci-gate-for-group-projects).
`DEPS_BASE_BRANCH` is `develop`, the branch Renovate opens its merge requests against.
Here the gate guards the group's Protected self-hosted runner (tag `self-hosted-runner`) that every job runs on;
the jobs read no stored secrets, and PyPI publishing uses OIDC on tag pipelines only.

## Translations

- [Русский](https://gitlab.com/eveo7/aa-discord-audit/-/blob/main/README.ru.md)

## License

MIT — see [`LICENSE`](https://gitlab.com/eveo7/aa-discord-audit/-/blob/main/LICENSE).
