Why
AuditEntry (modules/audit_log/audit_log/models.py:25) records changes across every module via a global session.audit_callback hook — under multi_tenant these rows must carry the tenant of the write that produced them, or a tenant admin viewing /admin/audit-log sees every tenant's activity, and a platform admin loses the ability to tell one tenant's history from another's.
Tables
AuditEntry (models.py:25) — adopt MultiTenantMixin: yes, but with a caveat: not every audited write happens inside a tenant context (host-owned tables, tenants module's own bootstrap, platform-admin actions). tenant_id should be nullable-at-the-model-level or the capture path (see below) needs a way to record "platform" entries distinctly rather than raising.
- No unique constraints exist on
AuditEntry today (__table_args__ at models.py:29-34 are all non-unique indexes: entity_type, entity_id, user_id, created_at) — SM024 is not triggered, but add tenant_id to the composite query indexes used by browse filtering (see Code paths).
Code paths
- Capture path has no tenant awareness at all:
audit_callback (capture.py:15-28) builds AuditEntry(...) from AuditRecord (framework/db/simple_module_db/audit.py:42-50), whose fields are entity_type, entity_id, action, changes, user_id, correlation_id — no tenant_id. The framework's collect_audit_records/snapshot_changes (audit.py) would need to read the flushing session's current tenant (e.g. session.info or the DatabaseState tenant context) and stamp it onto AuditRecord, since audit_log itself must stay ignorant of which mixin models exist elsewhere.
- All read queries are unscoped and must become tenant-scoped once the column exists:
service.py:113-127 (list_filtered), service.py:132-171 (iter_entries, used by export), service.py:173-181 (distinct_entity_types — the filter dropdown, which currently reads facets across all tenants).
/admin/audit-log is a platform-wide screen today (module.py:30-61, MenuSection.ADMIN_SIDEBAR) — once tenants exist, decide whether this stays a platform-admin, all-tenants view (wrap its queries in all_tenants() and add a tenant column to the UI) or becomes per-tenant with a separate platform screen. The design doc's /admin/tenants precedent suggests platform-wide screens are legitimate all_tenants() users — this module needs an explicit decision either way, since right now every install's audit trail is single-tenant-shaped code with no branching.
- Export (
export.py, streaming CSV of iter_entries) inherits whatever scoping iter_entries gets — must not silently export cross-tenant rows to a tenant-scoped export button.
- No caches to worry about (query service is read-through, no memoization).
- No public/anonymous routes — audit_log has no
register_public_routes and its view is ADMIN_SIDEBAR-gated.
Migration
- Add
tenant_id to audit_log_auditentry. Existing rows have no recorded tenant; backfill to a sentinel "platform" tenant id (or leave NULL and treat NULL as "platform/pre-tenancy" in query logic, mirroring how the framework already treats missing-context specially) — recommend NULL + an explicit all_tenants()-style bucket, since forcing a fabricated tenant on genuinely platform-level historical rows (e.g. tenants_tenant creation itself) would misattribute them.
- Add
tenant_id to the entity_type/user_id/created_at indexes used by the browse screen's common filter combinations (composite (tenant_id, created_at) at minimum, mirroring iter_entries' keyset order).
- Framework change required first:
AuditRecord and collect_audit_records need a tenant_id field before this module can populate it — call this out as a shared prerequisite with any other module capturing audit trails.
Tests
- Read isolation: a tenant-scoped audit-log view shows only that tenant's entries even when another tenant wrote in the same second (ordering collision check against
iter_entries' (created_at, id) cursor).
- Write path: an update made under
tenant_context(A) produces an AuditEntry with tenant_id=A; one made under all_tenants()/no context produces the agreed "platform" representation (NULL or sentinel) rather than raising or silently defaulting to the wrong tenant.
- Export: CSV export scoped to tenant A never includes tenant B rows, including for the "no filter" (export everything currently visible) case.
distinct_entity_types() facet list does not leak entity types that only exist in another tenant's data (if genuinely tenant-scoping this screen).
Out of scope / open questions
- Whether
/admin/audit-log becomes tenant-scoped or stays a platform-wide all_tenants() screen is a product decision this issue does not make — it blocks the migration's NULL-vs-sentinel choice above and should be settled before implementation.
- The framework-level
AuditRecord/collect_audit_records change (adding tenant capture) is a prerequisite change to simple_module_db, not to this module, but is called out here since audit_log cannot proceed without it.
Context: part of the SaaS tenancy work in #370 (fail-closed isolation, tenant_context()/all_tenants(), the tenants module). Design: docs/plans/2026-09-27-saas-tenancy-design.md and docs/framework/multi-tenancy.md on that branch.
Why
AuditEntry(modules/audit_log/audit_log/models.py:25) records changes across every module via a globalsession.audit_callbackhook — undermulti_tenantthese rows must carry the tenant of the write that produced them, or a tenant admin viewing/admin/audit-logsees every tenant's activity, and a platform admin loses the ability to tell one tenant's history from another's.Tables
AuditEntry(models.py:25) — adoptMultiTenantMixin: yes, but with a caveat: not every audited write happens inside a tenant context (host-owned tables,tenantsmodule's own bootstrap, platform-admin actions).tenant_idshould be nullable-at-the-model-level or the capture path (see below) needs a way to record "platform" entries distinctly rather than raising.AuditEntrytoday (__table_args__at models.py:29-34 are all non-unique indexes:entity_type,entity_id,user_id,created_at) — SM024 is not triggered, but addtenant_idto the composite query indexes used by browse filtering (see Code paths).Code paths
audit_callback(capture.py:15-28) buildsAuditEntry(...)fromAuditRecord(framework/db/simple_module_db/audit.py:42-50), whose fields areentity_type, entity_id, action, changes, user_id, correlation_id— notenant_id. The framework'scollect_audit_records/snapshot_changes(audit.py) would need to read the flushing session's current tenant (e.g.session.infoor theDatabaseStatetenant context) and stamp it ontoAuditRecord, since audit_log itself must stay ignorant of which mixin models exist elsewhere.service.py:113-127(list_filtered),service.py:132-171(iter_entries, used by export),service.py:173-181(distinct_entity_types— the filter dropdown, which currently reads facets across all tenants)./admin/audit-logis a platform-wide screen today (module.py:30-61,MenuSection.ADMIN_SIDEBAR) — once tenants exist, decide whether this stays a platform-admin, all-tenants view (wrap its queries inall_tenants()and add a tenant column to the UI) or becomes per-tenant with a separate platform screen. The design doc's/admin/tenantsprecedent suggests platform-wide screens are legitimateall_tenants()users — this module needs an explicit decision either way, since right now every install's audit trail is single-tenant-shaped code with no branching.export.py, streaming CSV ofiter_entries) inherits whatever scopingiter_entriesgets — must not silently export cross-tenant rows to a tenant-scoped export button.register_public_routesand its view isADMIN_SIDEBAR-gated.Migration
tenant_idtoaudit_log_auditentry. Existing rows have no recorded tenant; backfill to a sentinel "platform" tenant id (or leave NULL and treat NULL as "platform/pre-tenancy" in query logic, mirroring how the framework already treats missing-context specially) — recommend NULL + an explicitall_tenants()-style bucket, since forcing a fabricated tenant on genuinely platform-level historical rows (e.g.tenants_tenantcreation itself) would misattribute them.tenant_idto theentity_type/user_id/created_atindexes used by the browse screen's common filter combinations (composite(tenant_id, created_at)at minimum, mirroringiter_entries' keyset order).AuditRecordandcollect_audit_recordsneed atenant_idfield before this module can populate it — call this out as a shared prerequisite with any other module capturing audit trails.Tests
iter_entries'(created_at, id)cursor).tenant_context(A)produces anAuditEntrywithtenant_id=A; one made underall_tenants()/no context produces the agreed "platform" representation (NULL or sentinel) rather than raising or silently defaulting to the wrong tenant.distinct_entity_types()facet list does not leak entity types that only exist in another tenant's data (if genuinely tenant-scoping this screen).Out of scope / open questions
/admin/audit-logbecomes tenant-scoped or stays a platform-wideall_tenants()screen is a product decision this issue does not make — it blocks the migration's NULL-vs-sentinel choice above and should be settled before implementation.AuditRecord/collect_audit_recordschange (adding tenant capture) is a prerequisite change tosimple_module_db, not to this module, but is called out here since audit_log cannot proceed without it.Context: part of the SaaS tenancy work in #370 (fail-closed isolation,
tenant_context()/all_tenants(), thetenantsmodule). Design:docs/plans/2026-09-27-saas-tenancy-design.mdanddocs/framework/multi-tenancy.mdon that branch.