
Reconciliation audit for Alliance Auth's Discord integration: finds guild members holding AA-managed roles that Auth never granted and strips or kicks them under an operator-controlled policy. Standalone community Django app.
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 before1.0.
The audit is safe by default:
InitialAuditAcknowledgement (admin or shell only).report for every category — destructive
actions are opt-in.AuditRun, AuditFinding, AuditInvocation,
ConfigChangeLog) are append-only at the manager and instance
layers; bulk update() / bulk_update() are blocked.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.
allianceauth.services.modules.discord) installed and configured
(bot token + guild)pip install aa-discord-audit
In your Auth local.py:
# `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:
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>.
Grant aa_discord_audit.run_audit to the operator role that runs
audits.
Run a dry-run audit:
python manage.py audit_discord_roles --action report
Review findings under Discord Audit → Audit runs in the Auth dashboard.
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.
Re-run with the destructive action of your choice when ready.
The manage_* codenames are split per blast radius so a junior with
manage_bot_account_uid cannot also defang the audit by editing
ManagedRoleException.
All settings are optional. Defaults are safe.
# 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
# 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
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.
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
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.AuditInvocation (triggered_by=BEAT), so the audit-the-auditor
trail covers unattended runs too.INTERRUPTED state on the next
tick. AA_DISCORD_AUDIT_BEAT_MIN_INTERVAL_MINUTES is a coarse floor
against a misconfigured fast schedule.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.
To schedule the beat tasks, add them to CELERYBEAT_SCHEDULE in your
local.py, for example:
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
}
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:
ProtectedDiscordMember, first-run lock) decided the outcome.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:
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.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).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.
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.
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
(Russian translation:
docs/METRICS.ru.md).
BotAccountUid table / AA_DISCORD_AUDIT_BOT_UIDS —
AA_DISCORD_AUDIT_AUTO_DISCOVER_BY_NICKNAME is reserved for v2 and
not implemented.1.0.docs/runbook.md
— operator runbook: bot Discord permissions, pre-flight checklist,
releasing the first-run lock, incident playbooks, diagnostic toggles.docs/performance.md
— audit_benchmark reference numbers and sizing implications.docs/METRICS.md
/ docs/METRICS.ru.md
— Prometheus metric catalog, label vocabulary, Grafana recipes.CHANGELOG.mdmake 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).
MIT — see LICENSE.
| 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 |
| 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). |
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). |
| 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). |