ADR-0022 — Dual-store admin audit: AuditLog (per-tenant compliance) + Event (cross-tenant analytics)¶
- Date: 2026-04-29
- Status: Accepted
- Author: Curaway engineering
- Context: Slice 2a admin Users implementation (PR #475 + followup PR)
Context¶
The admin Users + Tenants endpoints (/api/v1/admin/users/*,
/api/v1/admin/tenants/*) write to two distinct audit stores on every
mutation. Without an explicit decision recorded, future contributors may
read this as duplication and remove one of the writes — silently breaking
either compliance or analytics.
The two stores existed before this slice but had not been used together on the same code path. Slice 2a is the first place where both fire on every grant/revoke; this ADR locks in the contract.
Decision¶
Every admin write emits one row in audit_log AND one (or more) rows in events.
| Store | Scope | Source | Use-case | Mutability |
|---|---|---|---|---|
audit_log |
Tenant-scoped (tenant_id is required) |
BaseRepository._audit('user_role.assign'/'.revoke'/...) (ADR-0016) |
Compliance / GDPR Article 17 / per-tenant accountability | Immutable — append-only; super_admin cannot delete |
events |
Cross-tenant (a single admin action may touch multiple tenants and is queried across tenants on the admin portal) | AdminUserService._emit_event(EventType.ADMIN_*, ...) |
Admin-action timeline UI, Metabase dashboards, anomaly detection | Immutable — append-only |
Both writes happen inside the same DB transaction, so they commit or
roll back atomically. The router's _commit_or_audit_failed helper
returns a typed 500 AUDIT_WRITE_FAILED when commit fails (caller can
retry; no partial state).
Event types emitted (slice 2a)¶
| Trigger | EventType | audit_log action |
|---|---|---|
| Grant (insert) | admin.user_role_granted |
user_role.assign |
| Grant (reactivate inactive) | admin.user_role_granted (was_reactivated=true) |
user_role.reactivate |
| Grant (no-op on active row) | admin.user_role_granted (was_idempotent_no_op=true) |
(no audit_log write — assign_role returns existing) |
| Revoke (soft-delete) | admin.user_role_revoked |
user_role.revoke |
| Revoke (idempotent on inactive row) | (no Event write — short-circuit) | (no audit_log write) |
actor_user_id == target_user_id |
additional admin.self_mutation |
(none — special case captured only in events) |
force=true bypassed last-admin guard |
additional admin.force_used |
(none — special case captured only in events) |
Consequences¶
Why both, not one¶
audit_logalone is insufficient for the admin portal because:- It is tenant-scoped — the admin Users page surfaces actions across every tenant a super_admin touches; a per-tenant index makes that query expensive and cross-tenant rows would need a special role.
- Self-mutation and force-bypass are admin-side facts about the action,
not per-tenant facts about the role assignment. Squeezing them into
audit_log.actionwould conflate "what changed" with "how it was authorised". eventsalone is insufficient for compliance because:- GDPR Article 17 erasure cascades follow
audit_log(tenant-scoped queries, fast index access ontenant_id+ actor + action). - Customer-facing tenant export (a future requirement) needs
per-tenant rows on a stable schema;
events.payloadis freer.
Cost¶
Two INSERTs per write. The volume on the admin Users path is bounded by
the number of admin operations per day (~tens, not thousands), so the
duplication is acceptable. If the cardinality ever grows by 10× we can
revisit by collapsing self-mutation/force into the audit_log.metadata
JSONB column.
Future extensions¶
- The same pattern is required for slice 2b (Tenants page). Reuse the
_emit_eventhelper onAdminTenantService. - When extracting an admin-service microservice, both stores must travel
with it; alternatively, ship
eventsto a dedicated analytics service via the existing event bus while keepingaudit_logco-located with the tenant data.
Pointers¶
- Code:
app/services/admin_user_service.py:_emit_event(cross-tenant Event);app/repositories/role_repository.py:UserRoleRepository._audit(tenant-scoped audit_log). - Spec:
docs/specs/admin-portal-users-tenants-feature.mddecisions 5-6. - Related: ADR-0016 DAO/Repository pattern; ADR-0018 multi-tenancy.