mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-18 00:14:38 +00:00
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:
1 parent
f5b02ed374
commit
cb5343397f
11 files changed
+483
-15
No files matched your search
@@ -0,0 +1,127 @@
|
||||
"""Migration round-trip test for 0034_auth_events_actor_target."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _alembic_ini() -> Path:
|
||||
return Path(__file__).resolve().parents[3] / "docsgpt" / "alembic.ini"
|
||||
|
||||
|
||||
def _run_alembic(url: str, *args: str) -> None:
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "alembic", "-c", str(_alembic_ini()), *args],
|
||||
timeout=60,
|
||||
env={**os.environ, "POSTGRES_URI": url},
|
||||
)
|
||||
|
||||
|
||||
def _alembic_heads(url: str) -> list[str]:
|
||||
out = subprocess.check_output(
|
||||
[sys.executable, "-m", "alembic", "-c", str(_alembic_ini()), "heads"],
|
||||
timeout=60,
|
||||
env={**os.environ, "POSTGRES_URI": url},
|
||||
text=True,
|
||||
)
|
||||
return [line for line in out.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _alembic_version(conn) -> str:
|
||||
return conn.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _column_exists(conn, table: str, column: str) -> bool:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :t AND column_name = :c AND table_schema = 'public'"
|
||||
),
|
||||
{"t": table, "c": column},
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _index_exists(conn, name: str) -> bool:
|
||||
row = conn.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = :n"),
|
||||
{"n": name},
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
_0034 = "0034_auth_events_actor_target"
|
||||
_0033 = "0033_quotas"
|
||||
|
||||
|
||||
class TestMigration0034RoundTrip:
|
||||
def test_single_head(self, pg_engine):
|
||||
url = pg_engine.url.render_as_string(hide_password=False)
|
||||
assert len(_alembic_heads(url)) == 1
|
||||
|
||||
def test_head_has_actor_target_columns(self, pg_engine):
|
||||
with pg_engine.connect() as conn:
|
||||
assert _alembic_version(conn) >= _0034
|
||||
assert _column_exists(conn, "auth_events", "actor_id")
|
||||
assert _column_exists(conn, "auth_events", "target_id")
|
||||
|
||||
def test_head_has_feed_indexes(self, pg_engine):
|
||||
"""The global feed orders by created_at with no user filter."""
|
||||
with pg_engine.connect() as conn:
|
||||
assert _index_exists(conn, "auth_events_created_idx")
|
||||
assert _index_exists(conn, "auth_events_event_created_idx")
|
||||
assert _index_exists(conn, "auth_events_actor_idx")
|
||||
|
||||
def test_downgrade_drops_then_upgrade_restores(self, pg_engine):
|
||||
url = pg_engine.url.render_as_string(hide_password=False)
|
||||
_run_alembic(url, "downgrade", _0033)
|
||||
with pg_engine.connect() as conn:
|
||||
assert _alembic_version(conn) == _0033
|
||||
assert not _column_exists(conn, "auth_events", "actor_id")
|
||||
assert not _column_exists(conn, "auth_events", "target_id")
|
||||
assert not _index_exists(conn, "auth_events_created_idx")
|
||||
_run_alembic(url, "upgrade", "head")
|
||||
with pg_engine.connect() as conn:
|
||||
assert _alembic_version(conn) >= _0034
|
||||
assert _column_exists(conn, "auth_events", "actor_id")
|
||||
|
||||
def test_backfill_reads_actor_out_of_metadata(self, pg_engine):
|
||||
"""An admin action filed under its target keeps the acting admin."""
|
||||
url = pg_engine.url.render_as_string(hide_password=False)
|
||||
_run_alembic(url, "downgrade", _0033)
|
||||
with pg_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO auth_events (user_id, event, metadata) VALUES "
|
||||
"('victim', 'admin_user_deactivated', '{\"by\": \"admin-1\"}'::jsonb), "
|
||||
"('grantee', 'role_granted', '{\"granted_by\": \"admin-2\"}'::jsonb), "
|
||||
"('someone', 'oidc_login', '{}'::jsonb), "
|
||||
"('actor-3', 'team.create', '{\"team_id\": \"t1\"}'::jsonb)"
|
||||
)
|
||||
)
|
||||
_run_alembic(url, "upgrade", "head")
|
||||
with pg_engine.connect() as conn:
|
||||
rows = dict(
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT event, actor_id || '|' || COALESCE(target_id, '-') "
|
||||
"FROM auth_events WHERE user_id IN "
|
||||
"('victim', 'grantee', 'someone', 'actor-3')"
|
||||
)
|
||||
).fetchall()
|
||||
)
|
||||
assert rows["admin_user_deactivated"] == "admin-1|victim"
|
||||
assert rows["role_granted"] == "admin-2|grantee"
|
||||
# Self-service events: the user is both actor and target.
|
||||
assert rows["oidc_login"] == "someone|someone"
|
||||
# Team events were always filed under the actor; they have no user target.
|
||||
assert rows["team.create"] == "actor-3|-"
|
||||
Reference in new issue
Block a user