feat(audit): split actor from subject on auth events

auth_events.user_id was overloaded and meant something different per call
site: admin mutations filed the row under the target user and hid the acting
admin in metadata->>'by', team events filed it under the actor, and quota
events switched between the two depending on scope. The practical consequence
was that "show me everything admin X did" had no answer.

Adds actor_id (never NULL) and target_id (NULL when the event is not about a
user) alongside the existing column, which keeps its meaning as the per-user
feed key so nothing historical is rewritten. The backfill recovers the actor
from metadata for rows that predate the columns.

Also adds the indexes the cross-user admin feed needs. The only index was
(user_id, created_at DESC), so the global feed -- which orders by created_at
with no user predicate -- fell back to a sequential scan plus a sort on every
page.

The feed gains actor, multi-event, until and free-text search filters, plus a
distinct-event catalogue so the UI can offer what the instance recorded
instead of asking operators to type a name from memory.
This commit is contained in:
arc53-machine committed 2026-09-22 10:18:16 +01:00
1 parent f5b02ed374
commit cb5343397f
11 files changed
+483 -15

No files matched your search

@@ -0,0 +1,94 @@
"""0034 auth events actor/target — who did it, and to whom.
``auth_events.user_id`` was overloaded: admin mutations file the row under the
*target* user and hide the acting admin in ``metadata->>'by'``, team events file
it under the *actor*, and quota events switch between the two depending on
scope. The practical consequence is that "show me everything admin X did" is
unanswerable — the first question any audit review asks.
This adds two explicit columns:
* ``actor_id`` — who performed the action (never NULL going forward).
* ``target_id`` — the user the action was performed on; NULL when the event is
not about a user (a team or an instance-wide policy change).
``user_id`` is deliberately left alone. It stays the per-user feed key
(``list_recent``) and every historical row keeps its meaning, so this migration
is additive and the backfill never rewrites it.
Backfill rules, applied only to rows that predate the columns:
* ``actor_id`` = the first present of ``metadata->>'by'``,
``metadata->>'granted_by'``, ``metadata->>'revoked_by'``, else ``user_id``.
* ``target_id`` = ``user_id``, except for ``team.*`` events, which were always
filed under the actor and have no single user target.
Also adds the indexes the global admin feed needs. Before this the only index
was ``(user_id, created_at DESC)``, so the cross-user feed — which orders by
``created_at DESC`` with no user predicate — degraded to a sequential scan plus
a sort on every page.
Idempotent both ways.
Revision ID: 0034_auth_events_actor_target
Revises: 0033_quotas
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0034_auth_events_actor_target"
down_revision: Union[str, None] = "0033_quotas"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TABLE auth_events ADD COLUMN IF NOT EXISTS actor_id TEXT;")
op.execute("ALTER TABLE auth_events ADD COLUMN IF NOT EXISTS target_id TEXT;")
# Backfill. ``actor_id IS NULL`` scopes this to pre-migration rows, so a
# re-run (or a downgrade/upgrade cycle) never clobbers written values.
op.execute(
"""
UPDATE auth_events
SET actor_id = COALESCE(
metadata->>'by',
metadata->>'granted_by',
metadata->>'revoked_by',
user_id
),
target_id = CASE
WHEN event LIKE 'team.%' THEN NULL
ELSE user_id
END
WHERE actor_id IS NULL;
"""
)
# Backfilled every row above, so the NOT NULL is safe to assert now. New
# rows always carry an actor (the repository derives one from ``user_id``).
op.execute("ALTER TABLE auth_events ALTER COLUMN actor_id SET NOT NULL;")
op.execute(
"CREATE INDEX IF NOT EXISTS auth_events_created_idx "
"ON auth_events (created_at DESC);"
)
op.execute(
"CREATE INDEX IF NOT EXISTS auth_events_event_created_idx "
"ON auth_events (event, created_at DESC);"
)
op.execute(
"CREATE INDEX IF NOT EXISTS auth_events_actor_idx "
"ON auth_events (actor_id, created_at DESC);"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS auth_events_actor_idx;")
op.execute("DROP INDEX IF EXISTS auth_events_event_created_idx;")
op.execute("DROP INDEX IF EXISTS auth_events_created_idx;")
op.execute("ALTER TABLE auth_events DROP COLUMN IF EXISTS target_id;")
op.execute("ALTER TABLE auth_events DROP COLUMN IF EXISTS actor_id;")