fix(admin): address review findings

- 0034's backfill set target_id to the acting admin for instance- and
  team-scoped quota policy changes, which are filed under the actor and have
  no user target. It now returns NULL for those, matching what quotas.py
  records going forward, with a test covering all three scopes.
- The CSV export wrote every cell verbatim. user_agent is an attacker's raw
  header, recorded without authenticating on a denied login, and the export
  is opened in a spreadsheet by an admin -- a cell starting "=" would be
  evaluated there. Every cell now has a leading formula trigger neutralized.
- The activity feed applied every response it received, so a slow reply for
  an old filter could overwrite the current one. Guarded by a request id,
  the same way settings/Analytics already does.
- The activity detail expander and the top-user drill-down were row onClick
  handlers, unreachable without a mouse. Both are real buttons now, the
  expander carrying aria-expanded and a label naming its event.
- FLOW_LABELS was applied to both breakdowns in the spend modal, so a model
  named "fallback" or "workflow" rendered as a flow description. Only the
  flow table maps keys now.
- Changing the range in that modal left the previous range's totals and
  chart on screen while the new request was in flight.
This commit is contained in:
arc53-machine committed 2026-09-22 11:13:11 +01:00
1 parent 64200a7f23
commit 2ea9d66789
7 files changed
+141 -25

No files matched your search

@@ -20,8 +20,9 @@ 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.
* ``target_id`` = ``user_id``, except for events that were always filed under
the actor and have no single user target: ``team.*``, and instance- or
team-scoped ``quota_policy_*`` changes.
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
@@ -62,6 +63,11 @@ def upgrade() -> None:
),
target_id = CASE
WHEN event LIKE 'team.%' THEN NULL
-- An instance or team quota policy is filed under the acting
-- admin, not a target user (see api/admin/quotas.py::_audit);
-- only a user-scoped policy has one.
WHEN event IN ('quota_policy_set', 'quota_policy_deleted')
AND COALESCE(metadata->>'scope', '') <> 'user' THEN NULL
ELSE user_id
END
WHERE actor_id IS NULL;
+19 -1
View File
@@ -209,6 +209,24 @@ def _export_rows(filters: dict, limit: int) -> Iterator[dict]:
)
# Characters a spreadsheet reads as the start of a formula.
_FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r")
def _csv_safe(value: object) -> object:
"""Neutralize a leading formula trigger before the value becomes a CSV cell.
The feed carries attacker-controlled text: ``user_agent`` is the raw header
of whoever made the request, and a denied login records one without ever
authenticating. An export is opened in a spreadsheet by an admin, so a cell
beginning ``=`` would be evaluated there. Prefixing an apostrophe keeps the
value readable and inert.
"""
if isinstance(value, str) and value.startswith(_FORMULA_TRIGGERS):
return f"'{value}"
return value
def _csv_rows(filters: dict, limit: int) -> Iterator[str]:
buffer = io.StringIO()
writer = csv.DictWriter(buffer, fieldnames=list(ACTIVITY_COLUMNS))
@@ -220,7 +238,7 @@ def _csv_rows(filters: dict, limit: int) -> Iterator[str]:
serialized["detail"] = json.dumps(
serialized.get("detail") or {}, default=str
)
writer.writerow(serialized)
writer.writerow({key: _csv_safe(value) for key, value in serialized.items()})
yield _drain(buffer)