Skip to content

audit_log: tag AuditEntry with tenant_id and tenant-scope the browse/export screens #372

Description

@antosubash

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions