Skip to content

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_log alone 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.action would conflate "what changed" with "how it was authorised".
  • events alone is insufficient for compliance because:
  • GDPR Article 17 erasure cascades follow audit_log (tenant-scoped queries, fast index access on tenant_id + actor + action).
  • Customer-facing tenant export (a future requirement) needs per-tenant rows on a stable schema; events.payload is 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_event helper on AdminTenantService.
  • When extracting an admin-service microservice, both stores must travel with it; alternatively, ship events to a dedicated analytics service via the existing event bus while keeping audit_log co-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.md decisions 5-6.
  • Related: ADR-0016 DAO/Repository pattern; ADR-0018 multi-tenancy.