Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/framework/multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,14 @@ Screens that take a tenant id from the URL can vet it without importing
`feature_flags` uses it to 404 on an unknown tenant when setting or listing
overrides; clearing stays unvalidated so a stale override can still be removed.

## Audit log

`audit_log` stamps every entry with the tenant bound when the write flushed,
`NULL` when none was (#372); the table is platform-wide, read with a tenant
filter. A platform admin's actions are attributed to their **active**
organisation when one is active — the write itself is scoped to it — and to the
platform only when nothing is bound. See [audit_log](/modules/audit_log#multi-tenancy).

## Testing

The `simple_module_test` plugin ships `tenant_client` (needs the `users` and
Expand Down
20 changes: 20 additions & 0 deletions docs/modules/audit_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,29 @@ from audit_log.contracts.schemas import AuditEntryRead, AuditEntryList
| `user_id` | `str(255) \| None` | indexed; from the request's `current_user_id` |
| `correlation_id` | `str(255) \| None` | request correlation id |
| `created_at` | `datetime` | indexed; tz-aware, `server_default = now()` |
| `tenant_id` | `str(50) \| None` | indexed; the tenant bound when the write happened, `NULL` for platform writes |

The table itself carries `__audit_exclude__ = True` so audit writes never re-enter the capture loop.

## Multi-tenancy

Each entry records the tenant bound when its write flushed (#372). The table is
deliberately **not** `MultiTenantMixin`: the audit log is a platform screen over
every tenant's entries, filtered by a *Tenant* control whose *Platform* choice
selects the entries with no tenant (writes made with nothing bound, or inside
`all_tenants()`).

The CSV export carries the tenant as its **last** column, after `changes`, so a
consumer that reads the file by position keeps working.

**Attribution follows the active tenant, not the actor's role.** A platform
admin who has an organisation active when they act is acting *in* that
organisation: the write is scoped to it, so its entry carries that tenant id
and appears under the tenant's filter, not under *Platform*. Only work done
with no tenant bound (no organisation active, a CLI command, a platform job)
is recorded as a platform entry. Filter by user to see everything one admin
did across tenants.

## Entity and actor names

An entry stores a model class name and a primary key, which proves what happened and names nobody. The browse screen, the CSV export and the users edit page's activity card all resolve those ids at render time through the audit-link registry: each module supplies a batch `label_resolver` naming its own rows, one query per entity type per page. Nothing is stored — the row keeps the ids it recorded.
Expand Down
40 changes: 40 additions & 0 deletions host/migrations/versions/a7c2e91d4b58_audit_log_entry_tenant_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""audit_log_audit_entry: add nullable tenant_id

NULL means a platform action (no tenant bound when the row was written), which
is also what every pre-existing row is. The table is deliberately not
``MultiTenantMixin``: platform writes have no tenant and strict mode would
raise inside the flush. See GH #372.

Revision ID: a7c2e91d4b58
Revises: 70786227af4c
Create Date: 2026-10-01 12:00:00.000000
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "a7c2e91d4b58"
down_revision: str | None = "70786227af4c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

_TABLE = "audit_log_audit_entry"
_SINGLE = "ix_audit_log_audit_entry_tenant_id"
_COMPOSITE = "ix_audit_entry_tenant_created"


def upgrade() -> None:
with op.batch_alter_table(_TABLE) as batch:
batch.add_column(sa.Column("tenant_id", sa.String(length=50), nullable=True))
batch.create_index(_SINGLE, ["tenant_id"], unique=False)
batch.create_index(_COMPOSITE, ["tenant_id", "created_at"], unique=False)


def downgrade() -> None:
with op.batch_alter_table(_TABLE) as batch:
batch.drop_index(_COMPOSITE)
batch.drop_index(_SINGLE)
batch.drop_column("tenant_id")
5 changes: 5 additions & 0 deletions modules/audit_log/audit_log/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import logging

from simple_module_db import current_tenant_id
from simple_module_db.audit import AuditRecord
from sqlalchemy.orm import Session

Expand All @@ -13,6 +14,9 @@


def audit_callback(session: Session, records: list[AuditRecord]) -> None:
# NULL = a platform action (no tenant bound). Deliberately not
# MultiTenantMixin: strict mode would raise inside the flush for those.
tenant_id = current_tenant_id.get()
try:
for record in records:
entry = AuditEntry(
Expand All @@ -22,6 +26,7 @@ def audit_callback(session: Session, records: list[AuditRecord]) -> None:
changes=record.changes,
user_id=record.user_id,
correlation_id=record.correlation_id,
tenant_id=tenant_id,
)
session.add(entry)
except Exception:
Expand Down
8 changes: 8 additions & 0 deletions modules/audit_log/audit_log/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,15 @@

DEFAULT_PAGE_SIZE: Final = 50
MAX_PAGE_SIZE: Final = 200
# Upper bound on ``page``: keeps OFFSET inside a 64-bit int (a ?page= of 10**20
# overflowed the driver and returned 500). Anything past the end is clamped to
# the last page by the view.
MAX_PAGE: Final = 1_000_000

PAGE_BROWSE: Final = f"{MODULE_NAME}/Browse"

STATUS_OK: Final = 200

TENANT_ID_MAX_LENGTH = 50
# Filter value meaning "entries with no tenant" (platform actions).
PLATFORM_TENANT_FILTER = "__platform__"
1 change: 1 addition & 0 deletions modules/audit_log/audit_log/contracts/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class AuditEntryRead(SQLModel):
changes: list[dict]
user_id: str | None = None
correlation_id: str | None = None
tenant_id: str | None = None
created_at: datetime


Expand Down
4 changes: 4 additions & 0 deletions modules/audit_log/audit_log/endpoints/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ async def list_audit_entries(
action: str | None = Query(default=None),
user_id: str | None = Query(default=None),
correlation_id: str | None = Query(default=None),
tenant_id: str | None = Query(default=None),
from_date: datetime | None = Query(default=None),
to_date: datetime | None = Query(default=None),
page: int = Query(default=1, ge=1),
Expand All @@ -41,6 +42,7 @@ async def list_audit_entries(
action=action,
user_id=user_id,
correlation_id=correlation_id,
tenant_id=tenant_id,
from_date=from_date,
to_date=to_date,
page=page,
Expand All @@ -58,6 +60,7 @@ async def export_audit_entries(
action: str | None = Query(default=None),
user_id: str | None = Query(default=None),
correlation_id: str | None = Query(default=None),
tenant_id: str | None = Query(default=None),
from_date: datetime | None = Query(default=None),
to_date: datetime | None = Query(default=None),
) -> StreamingResponse:
Expand All @@ -80,6 +83,7 @@ async def export_audit_entries(
correlation_id=correlation_id,
from_date=from_date,
to_date=to_date,
tenant_id=tenant_id,
)

return StreamingResponse(
Expand Down
6 changes: 6 additions & 0 deletions modules/audit_log/audit_log/endpoints/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
MAX_PAGE_SIZE,
PAGE_BROWSE,
PERM_VIEW,
PLATFORM_TENANT_FILTER,
)
from audit_log.deps import AuditLogServiceDep
from audit_log.filters import EntryFilters
Expand Down Expand Up @@ -77,6 +78,7 @@ async def browse(
action: str | None = Query(default=None),
user_id: str | None = Query(default=None),
correlation_id: str | None = Query(default=None),
tenant_id: str | None = Query(default=None),
from_date: datetime | None = Query(default=None),
to_date: datetime | None = Query(default=None),
page: str | None = Query(default=None),
Expand All @@ -97,6 +99,7 @@ async def browse(
correlation_id=correlation_id,
from_date=from_date,
to_date=to_date,
tenant_id=tenant_id or None,
)

result = await service.list_filtered(filters, page=page_int, page_size=page_size_int)
Expand Down Expand Up @@ -152,6 +155,8 @@ async def browse(
"page": result.page,
"page_size": result.page_size,
"entity_types": entity_types,
"tenant_ids": await service.distinct_tenant_ids(),
"platform_tenant_value": PLATFORM_TENANT_FILTER,
"export_url": f"{API_PREFIX}/export.csv",
"filters": {
"entity_type": entity_type,
Expand All @@ -161,6 +166,7 @@ async def browse(
# what was typed into it.
"user_id": actor_term or None,
"correlation_id": correlation_id,
"tenant_id": tenant_id or None,
"from_date": from_date.date().isoformat() if from_date else None,
"to_date": to_date.date().isoformat() if to_date else None,
},
Expand Down
15 changes: 14 additions & 1 deletion modules/audit_log/audit_log/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,19 @@
from audit_log.resolve import resolve_actors, resolve_entity_labels
from audit_log.service import AuditLogService

CSV_COLUMNS = ("time", "action", "entity_type", "entity_id", "entity_label", "actor", "changes")
CSV_COLUMNS = (
"time",
"action",
"entity_type",
"entity_id",
"entity_label",
"actor",
"changes",
# Appended last, not slotted in beside ``actor``: consumers that read the
# file by position (a spreadsheet macro, ``cut -d,``) predate the column
# and must keep finding ``changes`` where it always was.
"tenant_id",
)
CSV_MEDIA_TYPE = "text/csv; charset=utf-8"
CSV_FILENAME = "audit-log.csv"
_ARROW = " → "
Expand Down Expand Up @@ -88,6 +100,7 @@ def _row(entry: AuditEntryRead, *, entity_label: str, actor: str) -> list[str]:
entity_label,
actor,
format_changes(entry.changes),
entry.tenant_id or "",
)
]

Expand Down
9 changes: 9 additions & 0 deletions modules/audit_log/audit_log/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from datetime import datetime, time
from typing import Any

from audit_log.constants import PLATFORM_TENANT_FILTER
from audit_log.models import AuditEntry


Expand Down Expand Up @@ -47,6 +48,8 @@ class EntryFilters:
correlation_id: str | None = None
from_date: datetime | None = None
to_date: datetime | None = None
# A tenant id, or PLATFORM_TENANT_FILTER for entries with no tenant.
tenant_id: str | None = None

@classmethod
def for_date_only_range(
Expand All @@ -59,6 +62,7 @@ def for_date_only_range(
correlation_id: str | None = None,
from_date: datetime | None = None,
to_date: datetime | None = None,
tenant_id: str | None = None,
) -> EntryFilters:
"""Filters for the screen's controls, whose Date range is date-only.

Expand All @@ -79,6 +83,7 @@ def for_date_only_range(
correlation_id=correlation_id,
from_date=from_date,
to_date=end_of_day(to_date),
tenant_id=tenant_id,
)

def conditions(self) -> list[Any]:
Expand All @@ -88,6 +93,10 @@ def conditions(self) -> list[Any]:
conditions.append(AuditEntry.entity_type == self.entity_type)
if self.entity_id:
conditions.append(AuditEntry.entity_id == self.entity_id)
if self.tenant_id == PLATFORM_TENANT_FILTER:
conditions.append(AuditEntry.tenant_id.is_(None))
elif self.tenant_id:
conditions.append(AuditEntry.tenant_id == self.tenant_id)
if self.action:
conditions.append(AuditEntry.action == self.action)
if self.user_id:
Expand Down
8 changes: 6 additions & 2 deletions modules/audit_log/audit_log/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,18 @@
"date_range_any": "Any date",
"date_range_reset": "Clear dates",
"apply": "Apply",
"clear": "Clear"
"clear": "Clear",
"tenant_label": "Tenant",
"tenant_all": "All tenants",
"tenant_platform": "Platform"
},
"table": {
"timestamp": "Time",
"action": "Action",
"entity": "Entity",
"user": "Actor",
"changes": "Changes"
"changes": "Changes",
"tenant": "Tenant"
},
"actions": {
"created": "created",
Expand Down
6 changes: 6 additions & 0 deletions modules/audit_log/audit_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
ENTITY_TYPE_MAX_LENGTH,
MODULE_PACKAGE,
TABLE_AUDIT_ENTRY,
TENANT_ID_MAX_LENGTH,
USER_ID_MAX_LENGTH,
)

Expand All @@ -31,6 +32,7 @@ class AuditEntry(Base, table=True): # ty: ignore[unsupported-base]
Index("ix_audit_entry_entity_id", "entity_id"),
Index("ix_audit_entry_user_id", "user_id"),
Index("ix_audit_entry_created_at", "created_at"),
Index("ix_audit_entry_tenant_created", "tenant_id", "created_at"),
)

id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
Expand All @@ -40,6 +42,10 @@ class AuditEntry(Base, table=True): # ty: ignore[unsupported-base]
changes: dict | list = Field(default_factory=list, sa_column=Column(JSON))
user_id: str | None = Field(default=None, max_length=USER_ID_MAX_LENGTH)
correlation_id: str | None = Field(default=None, max_length=CORRELATION_ID_MAX_LENGTH)
# Not MultiTenantMixin on purpose: platform writes have no tenant and strict
# mode would raise inside the flush. NULL means a platform action; the admin
# screens read across tenants and filter on this column.
tenant_id: str | None = Field(default=None, max_length=TENANT_ID_MAX_LENGTH, index=True)
created_at: datetime = Field(
default_factory=lambda: datetime.now(UTC),
sa_type=DateTime(timezone=True),
Expand Down
Loading
Loading