Files
arc53-machine 25c82003d7 fix(admin): second review pass
Correctness
- /api/remote never recorded source.created, so URL, GitHub and connector
  sources had a source.deleted with no matching creation. All three creation
  paths now go through one _audit_source_created helper.
- The prompt-cache rate divided cached tokens by a whole bucket's prompt
  tokens. A bucket is a day and mixes calls whose provider reports a cache
  breakdown with calls whose provider does not, so filtering buckets in the
  client could not separate them and the rate was understated by however much
  traffic ran on a non-reporting provider. The denominator is now computed in
  SQL over the reporting rows.
- The outcome pill matched values nothing writes. Guardrails emit triggered /
  not_evaluated and the device feed emits dispatched; the map had blocked /
  denied / allowed, so a guardrail that fired rendered neutral grey -- the one
  signal the merged feed exists to surface. Fixtures were seeding the
  fictional values, so the tests passed on it too.
- Stream duration_ms timed the consumer. stream_token_usage is a generator,
  so start-to-exhaustion includes the agent loop's tool handling and the SSE
  client's pace; a slow browser recorded ~30s for a sub-second call. It now
  accumulates only the time spent inside next().

Safety
- Activity filters failed open: an unknown facet or unparseable timestamp was
  dropped, and no filter means every row, so a typo widened an audit view and
  on the export streamed the full history. Both are now a 400.
- The search term was interpolated into an ILIKE pattern, so "100%" matched
  everything and "q1_report" matched more than it should. Escaped.
- 0034 set actor_id NOT NULL with no default. A previous-release process
  inserting mid-rollout would raise, and in admin/routes.py that insert shares
  the request transaction, so a role grant beside it would roll back too.

Noise and dead code
- The per-user panel is a security panel: data-plane events file under the
  actor, so an active account's routine deletes pushed a denied login out of
  the 20-row window. It now excludes them; the Activity tab shows everything.
- device_audit_log had no created_at-leading index, so the merged feed
  sequentially scanned that branch every page (migration 0036).
- conversation.deleted_all no longer records when nothing was deleted, and
  agent.updated no longer records an empty field list.
- Dropped by_model from /admin/usage (no consumer; an extra aggregate per page
  load), the duplicate filter surface on AuthEventsRepository that nothing
  called, and the unreachable FLOW_LABELS.schedule entry.
- Type hints on record_event's conn and the remaining unannotated helpers.
2026-09-22 12:47:40 +01:00

372 lines
13 KiB
Python

"""One merged, normalized feed over the instance's three audit journals.
DocsGPT keeps three append-only journals, each written by a different part of
the system and each previously readable only on its own terms:
* ``auth_events`` — identity, access, configuration and (since the data-plane
hooks) resource mutations.
* ``device_audit_log`` — every remote-device command dispatch and its outcome.
* ``guardrail_events`` — every guardrail decision on a request.
An operator reviewing an incident wants one timeline, not three. This
repository projects all three onto a common row shape and merges them with
``UNION ALL``, so a single ordered, filtered, paginated feed spans the lot.
Two exclusions are deliberate and mirror the per-agent guardrail view: the
``guardrail_events.api_key`` column is a raw agent key, and ``matched_value``
is unredacted source text. Neither is projected here, admin-gated or not.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Iterator, Optional, Sequence
from sqlalchemy import Connection, text
from docsgpt.audit_events import category_case_sql
from docsgpt.storage.db.base_repository import like_escape
# Columns every branch of the union produces, in order.
ACTIVITY_COLUMNS = (
"feed",
"id",
"event",
"category",
"actor_id",
"target_id",
"ip",
"user_agent",
"outcome",
"detail",
"created_at",
)
# Which journal each feed name reads from. Used to skip a branch entirely when
# the category filter cannot match it.
_FEED_CATEGORIES = {"auth": None, "device": "device", "guardrail": "safety"}
# ``feed`` and ``id`` break ties so a page boundary is deterministic and the
# keyset cursor below has something total to compare against.
_ORDER_BY = "ORDER BY created_at DESC, feed DESC, id DESC"
_AUTH_BRANCH = f"""
SELECT 'auth' AS feed,
id::text AS id,
event,
{category_case_sql("event")} AS category,
actor_id,
target_id,
ip,
user_agent,
NULL::text AS outcome,
metadata AS detail,
created_at
FROM auth_events
"""
_DEVICE_BRANCH = """
SELECT 'device' AS feed,
id::text AS id,
'device.' || action AS event,
'device' AS category,
user_id AS actor_id,
NULL::text AS target_id,
NULL::text AS ip,
NULL::text AS user_agent,
decision AS outcome,
jsonb_strip_nulls(jsonb_build_object(
'device_id', device_id,
'invocation_id', invocation_id,
'command', command,
'working_dir', working_dir,
'approval_mode', approval_mode,
'decision_reason', decision_reason,
'exit_code', exit_code,
'duration_ms', duration_ms,
'agent_id', agent_id,
'conversation_id', conversation_id
)) AS detail,
created_at
FROM device_audit_log
"""
_GUARDRAIL_BRANCH = """
SELECT 'guardrail' AS feed,
g.id::text AS id,
'guardrail.' || g.stage AS event,
'safety' AS category,
g.user_id AS actor_id,
NULL::text AS target_id,
NULL::text AS ip,
NULL::text AS user_agent,
g.outcome,
jsonb_strip_nulls(jsonb_build_object(
'check_name', g.check_name,
'detector_type', g.detector_type,
'action', g.action,
'category', g.category,
'score', g.score,
'match_count', g.match_count,
'agent_id', g.agent_id,
'message_id', g.message_id,
'request_id', g.request_id,
'detail', g.detail
)) AS detail,
g.created_at
FROM guardrail_events g
"""
_BRANCHES = {
"auth": _AUTH_BRANCH,
"device": _DEVICE_BRANCH,
"guardrail": _GUARDRAIL_BRANCH,
}
class ActivityRepository:
"""Read-only merged feed over ``auth_events`` + the two side journals."""
def __init__(self, conn: Connection) -> None:
self._conn = conn
@staticmethod
def _feeds_for(
feeds: Optional[Sequence[str]], categories: Optional[Sequence[str]]
) -> list[str]:
"""Which union branches can still produce a matching row.
A ``category=device`` filter cannot be satisfied by ``auth_events``, so
that branch is dropped from the union rather than scanned and
discarded. ``auth`` spans several categories and is only dropped when
the filter names none of them.
"""
selected = list(feeds) if feeds else list(_BRANCHES)
selected = [feed for feed in selected if feed in _BRANCHES]
if not categories:
return selected
wanted = set(categories)
fixed_only = {category for category in _FEED_CATEGORIES.values() if category}
keep = []
for feed in selected:
fixed = _FEED_CATEGORIES[feed]
if fixed is None:
# ``auth`` spans several categories, so keep it unless the
# filter names only categories another journal owns outright.
if wanted - fixed_only:
keep.append(feed)
elif fixed in wanted:
keep.append(feed)
return keep
def _query(
self,
*,
feeds: Optional[Sequence[str]] = None,
categories: Optional[Sequence[str]] = None,
events: Optional[Sequence[str]] = None,
actor_id: Optional[str] = None,
user_id: Optional[str] = None,
since: Optional[datetime] = None,
until: Optional[datetime] = None,
search: Optional[str] = None,
) -> tuple[str, dict]:
selected = self._feeds_for(feeds, categories)
if not selected:
return "", {}
union = " UNION ALL ".join(_BRANCHES[feed] for feed in selected)
clauses: list[str] = []
params: dict[str, Any] = {}
if categories:
clauses.append("category = ANY(:categories)")
params["categories"] = list(categories)
if events:
clauses.append("event = ANY(:events)")
params["events"] = list(events)
if actor_id:
clauses.append("actor_id = :actor_id")
params["actor_id"] = actor_id
if user_id:
clauses.append("(actor_id = :user_id OR target_id = :user_id)")
params["user_id"] = user_id
if since is not None:
clauses.append("created_at >= :since")
params["since"] = since
if until is not None:
clauses.append("created_at <= :until")
params["until"] = until
if search:
# ``search`` is a literal substring, not a pattern: an operator
# looking for "100%" or "q1_report" must not have those characters
# read as wildcards.
clauses.append(
"(actor_id ILIKE :search ESCAPE '\\' "
"OR target_id ILIKE :search ESCAPE '\\' "
"OR event ILIKE :search ESCAPE '\\' "
"OR ip ILIKE :search ESCAPE '\\' "
"OR COALESCE(outcome, '') ILIKE :search ESCAPE '\\' "
"OR detail::text ILIKE :search ESCAPE '\\')"
)
params["search"] = f"%{like_escape(search)}%"
# Always a WHERE, even with no filters, so the keyset cursor below can
# append " AND ..." without sniffing the string it was handed.
where = "WHERE " + " AND ".join(clauses or ["TRUE"])
return f"SELECT * FROM ({union}) AS activity {where}", params
def list(
self,
*,
feeds: Optional[Sequence[str]] = None,
categories: Optional[Sequence[str]] = None,
events: Optional[Sequence[str]] = None,
actor_id: Optional[str] = None,
user_id: Optional[str] = None,
since: Optional[datetime] = None,
until: Optional[datetime] = None,
search: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> list[dict]:
"""Return one page of the merged feed, newest first.
Args:
feeds: Restrict to named journals (``auth``, ``device``, ``guardrail``).
categories: Restrict to activity categories.
events: Restrict to exact event names.
actor_id: Only rows this user performed.
user_id: Rows where this user is the actor or the target.
since: Inclusive lower bound on ``created_at``.
until: Inclusive upper bound on ``created_at``.
search: Case-insensitive substring over ids, event, IP and detail.
limit: Page size.
offset: Rows to skip.
Returns:
Row dicts carrying :data:`ACTIVITY_COLUMNS`.
"""
sql, params = self._query(
feeds=feeds,
categories=categories,
events=events,
actor_id=actor_id,
user_id=user_id,
since=since,
until=until,
search=search,
)
if not sql:
return []
params.update({"limit": int(limit), "offset": int(offset)})
result = self._conn.execute(
text(f"{sql} {_ORDER_BY} LIMIT :limit OFFSET :offset"),
params,
)
return [dict(row._mapping) for row in result.fetchall()]
def _page_after(
self,
cursor: Optional[tuple[datetime, str, str]],
limit: int,
filters: dict,
) -> list[dict]:
"""One keyset page strictly older than ``cursor``.
OFFSET paging would drift here: the feed is ordered newest-first and
the journals are append-only, so rows inserted mid-export push the
window down and repeat rows that were already emitted.
"""
sql, params = self._query(**filters)
if not sql:
return []
if cursor is not None:
sql += " AND (created_at, feed, id) < (:cur_at, :cur_feed, :cur_id)"
params.update(
{"cur_at": cursor[0], "cur_feed": cursor[1], "cur_id": cursor[2]}
)
params["limit"] = int(limit)
result = self._conn.execute(text(f"{sql} {_ORDER_BY} LIMIT :limit"), params)
return [dict(row._mapping) for row in result.fetchall()]
def count(
self,
*,
feeds: Optional[Sequence[str]] = None,
categories: Optional[Sequence[str]] = None,
events: Optional[Sequence[str]] = None,
actor_id: Optional[str] = None,
user_id: Optional[str] = None,
since: Optional[datetime] = None,
until: Optional[datetime] = None,
search: Optional[str] = None,
) -> int:
"""Total rows matching the same filters as :meth:`list`."""
sql, params = self._query(
feeds=feeds,
categories=categories,
events=events,
actor_id=actor_id,
user_id=user_id,
since=since,
until=until,
search=search,
)
if not sql:
return 0
scalar = self._conn.execute(
text(f"SELECT count(*) FROM ({sql}) AS matched"), params
).scalar()
return int(scalar or 0)
def iter_all(
self,
*,
chunk_size: int = 1000,
max_rows: int = 100_000,
**filters: Any,
) -> Iterator[dict]:
"""Yield every matching row, newest first, for the export endpoint.
Pages by keyset, not offset: the journals are append-only and the feed
is newest-first, so rows written mid-export would otherwise shift the
window and repeat rows already emitted.
Args:
chunk_size: Rows fetched per round trip.
max_rows: Hard ceiling; an export stops here rather than running
unbounded against a busy instance.
**filters: As :meth:`list`, minus ``limit`` / ``offset``.
Yields:
Row dicts carrying :data:`ACTIVITY_COLUMNS`.
"""
emitted = 0
cursor: Optional[tuple[datetime, str, str]] = None
while emitted < max_rows:
page = self._page_after(
cursor, min(chunk_size, max_rows - emitted), filters
)
if not page:
return
for row in page:
yield row
last = page[-1]
cursor = (last["created_at"], last["feed"], last["id"])
emitted += len(page)
def event_names(self) -> list[dict]:
"""Distinct ``(event, category)`` pairs across all three journals.
Feeds the filter's event picker, so operators choose from what the
instance actually recorded rather than typing a name from memory.
"""
union = " UNION ALL ".join(_BRANCHES.values())
result = self._conn.execute(
text(
f"SELECT DISTINCT event, category FROM ({union}) AS activity "
"ORDER BY category, event"
)
)
return [{"event": row[0], "category": row[1]} for row in result.fetchall()]