shipped (model + endpoint + web page)

Spec: F16 — Audit Logging

Status: shipped (model + endpoint + web page) · Audited: 2026-07-16 · Contract: partial; see F16 contract audit

1. Problem & CEO Job

Trust the signal — at the system level. A multi-tenant tool holding a company’s risks, debts, and decisions must be able to answer “who did what, when?” for security review, customer due diligence, and incident forensics. This is infrastructure: invisible until the day it’s the only thing that matters.

2. User Stories & Acceptance Criteria

  • As an owner, I want sensitive actions recorded immutably, so that disputes and incidents are resolvable from fact.
    • Given a recorded action, then AuditLog holds actor (OrganizationMember), action verb, target type+id, metadata, timestamp. Verified: backend/apps/audit/models.py:7-11 + backend/core/models.py:14 (created_at).
    • Logs are append-only via the API (read-only ViewSet — no update/delete endpoints). Verified: backend/apps/audit/views.py:10 extends ReadOnlyModelViewSet.
    • Logs are tenant-scoped: an owner sees only their org’s trail. Verified: backend/apps/audit/views.py:24 filters by organization_id. Cross-tenant test at backend/tests/test_approvals.py:563.
  • Billing gate: audit-log access requires an upgraded plan with has_audit_log=True. An admin on a lower plan (or one with an active subscription where plan.has_audit_log is False) receives 403 from get_queryset before any log rows are returned. Orgs with no active subscription are default-allowed (dev/demo convenience). Verified: backend/apps/audit/views.py:14-22.
  • Minimum logged surface: member lifecycle (invite/role-change/suspend/remove), login, org settings changes, decision decide/cancel, deletions of business objects. The first-party AuditLog is authoritative for this operational/security trail. ADR-046 defines django-auditlog as supplementary registered-model history; django-simple-history has no current model registration and is not an authoritative F16 reader.

3. Scope Boundaries

  • In: AuditLog model, read-only list endpoint, logging of sensitive mutations, in-app org-admin audit log page (admin/owner only — added 2026-06-19, see §6 + Change Log).
  • Out: log export, SIEM integration, retention policies (revisit pre-enterprise).

4. Data Model

audit.AuditLog — actor FK (SET_NULL — logs outlive members), action string, target_type/target_id, metadata_json, ordered by -created_at.

5. API Contract

MethodPathMin roleNotes
GET/api/audit-logs/adminRead-only router ViewSet, tenant-scoped. Page default; pagination=cursor opt-in. Also requires plan.has_audit_log=True — admins on lower plans receive 403.
GET/api/audit-logs/<id>/adminTenant-scoped read-only detail operation from the same ViewSet; preserves the billing and authorization boundary.

Cursor mode traverses immutable audit history by descending UUIDv7 id, preserves action and target_type filters, rejects alternate ordering, and retains the four-key page response envelope for shipped clients.

6. UI / UX

Org-admin audit log page (added 2026-06-19). Route /settings/audit-log, surfaced in the Sidebar’s admin-only Organization group (visible only to owner/admin, matching IsOrgOwnerOrAdmin on the API). Page-level guard: a non-admin who reaches the route sees an “Admins only” notice, never log data.

  • File: frontend/src/pages/settings/AuditLogPage.tsx · Hook: frontend/src/hooks/queries/useAuditLogs.ts · API: auditLogsApi.list in frontend/src/api/endpoints.ts · i18n: audit namespace (en/vi).
  • Layout: read-only table — When (localized timestamp), Who (actor name/email), Action (verb badge), Target (type · short id). Filter dropdowns for action and target type (server-side ?action=/?target_type= params). Loading skeleton, empty state, and error card. No mutation affordances — the trail stays append-only, consistent with the read-only ViewSet.

Rationale for the reversal of the original “no UI” decision: edit/delete affordances added across Projects/Tasks/Recurring/CRM/Hub (F04/F05/F06/F27) make destructive actions routine, so org admins need a first-class, in-product way to answer “who changed/deleted what?” without DB/admin access.

7. Test Plan

#CaseTypeWhere
1List endpoint, tenant scoping, role gatebackendtests/ suite (100% coverage gate)
2Sensitive mutations write a log rowbackendper-action assertions as actions are wired (see §11 audit)
3Page compatibility, cursor traversal, tenant isolation, filter retention, no SQL OFFSET, indexbackendtests/core/test_high_value_cursor_pagination.py

8. Current Behavior

Shipped. AuditLog model and read-only admin endpoint exist. Minimum logged surface (member lifecycle, org settings, decision decide/cancel, business-object deletions) was fully instrumented in 2026-06-11; login was added 2026-07-10 (see §13). ADR-046 defines the division: first-party AuditLog owns operational/security events, django-auditlog supplements registered-model history, and simple-history is not currently registered or authoritative.

9. Error States

  • Non-admin requests to GET /api/audit-logs/. Member role receives 403; spec does not define the 403 response body shape.
  • Empty log table. List returns 200 with empty array. Not an error but should be tested explicitly.
  • Actor FK null (deleted member). If the actor FK is null (SET_NULL), the list serializer returns HTTP 200 with actor, actor_email, and actor_name all set to null, not a 500. Proof: TestAuditLogTenantIsolation.test_deleted_member_actor_is_serialized_as_null (F16.AC001.ACTOR_NULL).
  • Invalid action value on write. action is a free string — no enum validation. A misspelled action string is still written. Consistency depends on caller discipline.
  • Unparseable metadata_json. No model-layer validation. Malformed JSON on a log write may fail silently or produce an unqueryable row.
  • Cross-tenant leak via loose reference. target_id has no FK → no tenant-scoped FK guard on log writes. The spec does not document cross-tenant validation on the write path.
  • Deep page-number scans. The default remains bounded to 50 rows, while pagination=cursor provides an indexed path that does not pay increasing OFFSET cost.

10. Diagrams & Docs Touched

backend/DIAGRAMS.md ER diagram.

11. Implementation Audit (2026-06-14)

#ClaimStatusEvidence
1AuditLog model: actor FK (SET_NULL), action, target_type, target_id, metadata_json, created_at✅ matchbackend/apps/audit/models.py:7-11; backend/core/models.py:14
2ReadOnlyModelViewSet — no update/delete endpoints✅ matchbackend/apps/audit/views.py:10 extends ReadOnlyModelViewSet
3get_queryset filters by organization_id (tenant-scoped)✅ matchbackend/apps/audit/views.py:24
4Admin-only access gate✅ matchViewSet role gate; cross-tenant test at test_approvals.py:563 (test_audit_log_cross_tenant_isolation)
5Member lifecycle (invite/role-change/suspend/remove) logged✅ match2026-06-11 feature commit
6Org settings changes logged✅ match2026-06-11 feature commit
7Decision decide/cancel logged✅ match2026-06-11 feature commit
8Business-object deletions (project, task, company, deal) logged✅ match2026-06-11 feature commit
9GET /api/audit-logs/ endpoint exists and is admin-only✅ match§5 API contract; 100% coverage gate passes
10Composite index on (organization_id, -created_at) for pagination performance✅ matchbackend/apps/audit/models.py defines audit_log_org_created_idx; migration backend/apps/audit/migrations/0004_add_audit_log_org_created_index.py; proof test backend/tests/test_api.py::TestAuditLogPerf::test_audit_log_index_exists
11Login is a logged action (member.login, per active org membership)✅ matchbackend/apps/accounts/views.py::login_view calls write_audit_log once per active OrganizationMember on successful auth; no entry on failed auth or for a user with no membership. Tests: backend/tests/organizations/test_audit_log.py::TestLoginWritesAuditLog

Gaps from Edge Cases (absorbed):

  • Actor FK null on list: closed by the fixture-backed API proof; the list response preserves the row and returns null actor fields.
  • Pagination strategy closed: page-number mode remains compatible and bounded; opt-in UUIDv7 cursor mode provides stable deep-history traversal.
  • Audit mechanism ownership — resolved by ADR-046: first-party AuditLog owns operational/security events; django-auditlog is supplementary for explicitly registered models; simple-history has no current registration. No cross-source export or consolidation is claimed.
  • action free string: no enum → no validation → consistency is caller-discipline only.
  • metadata_json no size limit: could inflate storage; not tracked.
  • target_id cross-tenant: no FK guard on write path — a bug in a logging callsite could write a cross-tenant target_id with no validation.

12. Open Questions / Decisions

  • Consolidate three audit mechanisms (own AuditLog, django-auditlog, simple-history) into a documented story — which answers which question? Small ADR when touched next time.
  • Pagination strategy: shipped as additive UUIDv7 cursor mode; migrate clients gradually before considering removal of exact COUNT(*) from a versioned response contract.
  • Retention policy: out of scope for MVP; flag when targeting enterprise accounts.

13. Bug / Change Log

2026-07-16 — Audit ownership decision

Type: Architecture decision and contract-boundary reconciliation

Summary:

  • Added ADR-046 to define the first-party AuditLog as the authoritative tenant-scoped operational/security trail.
  • Documented django-auditlog as supplementary registered-model history and django-simple-history as unregistered future capacity.
  • Removed the ownership contradiction; nullable actor, loose target reference, and browser evidence remain open.

2026-07-14 — Contract Graph ownership and honest proof boundary

Type: Verification

Summary:

  • Added the F16 CUE contract for the tenant-scoped API, billing gate, cursor behavior, and admin web page.
  • Added collected pytest and Vitest proof markers and made the browser, audit-mechanism ownership, and target-reference decisions explicit blocking gaps. The nullable-actor response contract is now executable and closed.

Impact:

  • F16 is no longer represented as an unqualified shipped row while its runtime and architectural evidence remain incomplete.

2026-07-14 — Mobile: keep Audit Log activity dates localized and inspectable (ACCT-05)

Type: UX fix (shared KMP audit log)

Summary:

  • Older Audit Log dates now use shared localized month resources instead of a numeric month pair, and unknown weekday values use localized fallback copy.
  • Added stable screen and row tags plus source assertions for the date path.

Scope boundary:

  • Source hardening only. ACCT-05 Android/iOS populated, role-hidden, Vietnamese, empty, error, and refresh evidence remains PM-owned mobile-audit work.

Tests:

  • git diff --check
  • PYENV_VERSION=system python3 scripts/check_docs_truth.py
  • bash scripts/check-platform-parity.sh

2026-07-13 — Additive UUIDv7 cursor pagination

Type: Performance + API compatibility

Added pagination=cursor for immutable audit history, backed by (organization, -id), while page mode remains the default. Cursor links preserve audit filters and the mobile response envelope. See docs/test-plan/high-value-cursor-pagination.md.

2026-07-10 — Fix data-truth gap: seeded demo org showed a dishonest empty audit log; add login auditing (W-ACCT-04)

Type: Bug Fix (honesty) / Feature

Summary:

  • Data-truth verdict: the audit-log page/query were correct — the gap was genuine (b): the demo org had zero AuditLog rows because seed_demo_lumi creates members/teams/org settings directly via the ORM, never through the write_audit_log-instrumented views (invite_member, update_member, update_organization). The Audit Log page therefore truthfully rendered “No audit entries yet.” on data that in fact had zero recorded actions — but the seed itself was the honesty bug: a demo org that looks lived-in (members, teams, deals, pulses) had no history of how it got that way.
  • Fix 1 (seed): backend/apps/accounts/management/commands/seed_demo_lumi.py now writes 6 backdated, tenant-scoped AuditLog rows for the demo org via the existing write_audit_log() helper (_seed_audit_log) — organization.updated, 3× member.invited, member.role_changed, member.login — spread over the last 28 days so the page shows a believable history instead of a wall of identical timestamps.
  • Fix 2 (real gap): login was never audited. Added §2’s “Minimum logged surface” item. login_view (backend/apps/accounts/views.py) now calls write_audit_log(action="member.login", ...) once per active OrganizationMember on successful authentication — tenant-scoped per org, never written for a failed login or a user with no membership.
  • Frontend (AuditLogPage.tsx, useAuditLogs.ts, i18n) required no changes — localized timestamps, actor/action/target columns, and action/target-type filters were already correct once real rows exist.

Tests: backend/tests/organizations/test_audit_log.py (new) — tenant isolation (own-org only, cross-tenant 403/empty), non-admin 403, action/target_type filters, login writes one entry per active membership, no entry on failed login or for a membership-less user. backend/tests/platform/test_seed.py (existing idempotency test) re-verified green with the new seed step.

2026-07-02 — Document billing-plan gate and correct stale line references

Type: Documentation truth fix

Summary:

  • §2 and §11: corrected stale views.py line references. AuditLogViewSet is at line 10 (was cited as :8); the org-filter AuditLog.objects.filter(organization_id=...) is at line 24 (was cited as :12). Cross-tenant isolation test test_audit_log_cross_tenant_isolation starts at test_approvals.py:563 (was cited as :567).
  • §2 and §5: added the billing-plan gate. get_queryset raises PermissionDenied if the org has an active subscription with plan.has_audit_log=False (enterprise-only flag). Admins on lower plans receive 403 before any log data is returned; orgs with no active subscription are default-allowed. Evidence: backend/apps/audit/views.py:14-22.

Tests: No new tests — the billing gate was already in views.py; only the spec was silent about it.

2026-07-01 — Close stale audit-log index gap

Type: Documentation truth fix

Summary:

  • Closed the F16 audit row that still described the composite audit-log index as “likely absent.”
  • The index is already shipped in the model, migration history, and a dedicated backend perf proof test; the real remaining scale concern is unbounded pagination, not index absence.

2026-06-19 — Add in-app org-admin audit log page (scope change)

Type: Feature / Scope Change

Summary:

  • Reversed the original §3 “audit UI page — Out” decision: added /settings/audit-log, an admin/owner-only read-only audit table (filter by action + target type) backed by the existing GET /api/audit-logs/ endpoint. Driven by the 2026-06-19 session goal that added user-facing edit/delete across work/CRM/hub — making an in-product “who did what” view necessary.
  • New frontend files: pages/settings/AuditLogPage.tsx, hooks/queries/useAuditLogs.ts, i18n/locales/{en,vi}/audit.json; wired route in App.tsx, nav item in Sidebar.tsx, audit namespace in i18n/index.ts, auditLogsApi in api/endpoints.ts. Test: src/test/AuditLogPage.test.tsx (member sees access notice; admin/owner see the table).
  • Backend unchanged — endpoint, tenant scoping, role gate, and billing gate already existed.

2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)

Type: Documentation Change

Summary:

  • Numbered “Current Behavior” as §8.
  • Sections renumbered: §9 Error States, §10 Diagrams, §11 Implementation Audit (expanded to 10-row table + gaps), §12 Open Questions, §13 Bug Log.
  • Edge Cases (§12) absorbed into §11 audit gaps.

2026-06-12 — Verified §2 acceptance criteria (CMD fleet agent D, round 19)

Type: Doc Fix / Verified

Summary:

  • All three §2 ACs confirmed: model fields at models.py:7-11, ReadOnlyModelViewSet at views.py:10, tenant-scoped at views.py:24. Cross-tenant isolation test at test_approvals.py:563. No new tests needed. (Line references corrected 2026-07-02.)

2026-06-12 — Added Edge Cases and Error States sections (CMD fleet agent D, round 11)

Type: Documentation

Summary:

  • Added §12 Edge Cases and §13 Error States.

2026-06-11 — Close minimum audit-logging surface

Type: Feature

Summary:

  • Added explicit AuditLog writes for org updates, member invite/role-change/suspend/remove, decision decide/cancel, and primary business-object deletions.
  • Restricted /api/audit-logs/ to owner/admin access as the spec requires.
  • Added backend assertions covering the minimum surface.

Impact:

  • F16 now has evidence-backed MVP coverage rather than a model-only claim.