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
AuditLogholds 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:10extendsReadOnlyModelViewSet. - Logs are tenant-scoped: an owner sees only their org’s trail. Verified:
backend/apps/audit/views.py:24filters byorganization_id. Cross-tenant test atbackend/tests/test_approvals.py:563.
- Given a recorded action, then
- 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 whereplan.has_audit_logis False) receives 403 fromget_querysetbefore 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
AuditLogis authoritative for this operational/security trail. ADR-046 definesdjango-auditlogas supplementary registered-model history;django-simple-historyhas no current model registration and is not an authoritative F16 reader.
3. Scope Boundaries
- In:
AuditLogmodel, 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
| Method | Path | Min role | Notes |
|---|---|---|---|
| GET | /api/audit-logs/ | admin | Read-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>/ | admin | Tenant-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.listinfrontend/src/api/endpoints.ts· i18n:auditnamespace (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
| # | Case | Type | Where |
|---|---|---|---|
| 1 | List endpoint, tenant scoping, role gate | backend | tests/ suite (100% coverage gate) |
| 2 | Sensitive mutations write a log row | backend | per-action assertions as actions are wired (see §11 audit) |
| 3 | Page compatibility, cursor traversal, tenant isolation, filter retention, no SQL OFFSET, index | backend | tests/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
actorFK is null (SET_NULL), the list serializer returns HTTP 200 withactor,actor_email, andactor_nameall set tonull, not a 500. Proof:TestAuditLogTenantIsolation.test_deleted_member_actor_is_serialized_as_null(F16.AC001.ACTOR_NULL). - Invalid
actionvalue on write.actionis 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_idhas 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=cursorprovides an indexed path that does not pay increasingOFFSETcost.
10. Diagrams & Docs Touched
backend/DIAGRAMS.md ER diagram.
11. Implementation Audit (2026-06-14)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | AuditLog model: actor FK (SET_NULL), action, target_type, target_id, metadata_json, created_at | ✅ match | backend/apps/audit/models.py:7-11; backend/core/models.py:14 |
| 2 | ReadOnlyModelViewSet — no update/delete endpoints | ✅ match | backend/apps/audit/views.py:10 extends ReadOnlyModelViewSet |
| 3 | get_queryset filters by organization_id (tenant-scoped) | ✅ match | backend/apps/audit/views.py:24 |
| 4 | Admin-only access gate | ✅ match | ViewSet role gate; cross-tenant test at test_approvals.py:563 (test_audit_log_cross_tenant_isolation) |
| 5 | Member lifecycle (invite/role-change/suspend/remove) logged | ✅ match | 2026-06-11 feature commit |
| 6 | Org settings changes logged | ✅ match | 2026-06-11 feature commit |
| 7 | Decision decide/cancel logged | ✅ match | 2026-06-11 feature commit |
| 8 | Business-object deletions (project, task, company, deal) logged | ✅ match | 2026-06-11 feature commit |
| 9 | GET /api/audit-logs/ endpoint exists and is admin-only | ✅ match | §5 API contract; 100% coverage gate passes |
| 10 | Composite index on (organization_id, -created_at) for pagination performance | ✅ match | backend/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 |
| 11 | Login is a logged action (member.login, per active org membership) | ✅ match | backend/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
AuditLogowns operational/security events;django-auditlogis supplementary for explicitly registered models;simple-historyhas no current registration. No cross-source export or consolidation is claimed. actionfree string: no enum → no validation → consistency is caller-discipline only.metadata_jsonno size limit: could inflate storage; not tracked.target_idcross-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
AuditLogas the authoritative tenant-scoped operational/security trail. - Documented
django-auditlogas supplementary registered-model history anddjango-simple-historyas 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 --checkPYENV_VERSION=system python3 scripts/check_docs_truth.pybash 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
AuditLogrows becauseseed_demo_lumicreates members/teams/org settings directly via the ORM, never through thewrite_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.pynow writes 6 backdated, tenant-scopedAuditLogrows for the demo org via the existingwrite_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 callswrite_audit_log(action="member.login", ...)once per activeOrganizationMemberon 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.pyline references.AuditLogViewSetis at line 10 (was cited as :8); the org-filterAuditLog.objects.filter(organization_id=...)is at line 24 (was cited as :12). Cross-tenant isolation testtest_audit_log_cross_tenant_isolationstarts attest_approvals.py:563(was cited as :567). - §2 and §5: added the billing-plan gate.
get_querysetraisesPermissionDeniedif the org has an active subscription withplan.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 existingGET /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 inApp.tsx, nav item inSidebar.tsx,auditnamespace ini18n/index.ts,auditLogsApiinapi/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,ReadOnlyModelViewSetatviews.py:10, tenant-scoped atviews.py:24. Cross-tenant isolation test attest_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
AuditLogwrites 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.