Skip to content

ADR-0011: Admin role boundaries

  • Status: Accepted (flipped from Proposed on 2026-05-11 when #45 landed the admin_audit_log table + emission wiring per the routing rule below).
  • Date: 2026-05-06 (Proposed) → 2026-05-11 (Accepted)
  • Decider(s): Theo (SA), with defaults pre-confirmed by Stefan
  • Closes: #122

Context

ADR-0006 §"Admin role" established the load-bearing rule: admin is a Stringer.role attribute, not a JWT claim. The JWT carries identity only (sub resolves to a Stringer or Person row); authorization — including admin — is RBO's domain. MR !59 (M21 invites) added the first reusable chokepoint at app/auth/admin.py::require_admin.

Round 5 multiplies the admin surface from one endpoint to four-plus:

Source Endpoint Status
MR !59 POST /admin/stringers/invite Shipped
MR !59 POST /admin/stringers/{id}/finalize Shipped
Pax-A Round 5 POST /admin/catalogue/rackets/{id}/promote Round 5
Pax-A Round 5 POST /admin/catalogue/rackets/{id}/reject Round 5
Pax-A Round 5 POST /admin/catalogue/strings/{id}/promote Round 5
Pax-A Round 5 POST /admin/catalogue/strings/{id}/reject Round 5
R-FADP-5 POST /admin/persons/finalize-expired Shipped (route stub)
Issue #45 admin_audit_log table + sink Round 5–6
docs/design/admin-person-merge.md Person-merge admin tool V2.x
Iris's DSAR queue (fadp-posture) DSAR review surface V2.x

Three problems compound across this surface:

  1. No single page enumerates the bypass scopes. Each admin route reasons locally about whether it bypasses tenancy (set_bypass_tenant), bypasses consent (e.g. catalogue moderation reads pending-shared rows from every stringer), or bypasses normal share-grant audit. The result: a future admin route author has to grep three places to know what an admin "is allowed to do" by convention.
  2. Audit-row routing is informal. share_audit (per ADR-0004) records grant/revoke/shared-read events. Other admin actions (catalogue promote, force-finalize, person merge) currently have no canonical audit destination. Issue #45 plans admin_audit_log for exactly this case but the contract — when to write to which table — is undocumented.
  3. The V3 role-switcher is named in ADR-0006 §"V3 client-portal slot" as an Open Question ("Stefan-as-both" — Stefan is a Stringer and a Person under his own tools, e.g. when he tests as a client). Architecturally we need to reserve the slot now so Pax does not have to retrofit it.

This ADR locks the role contract: who is admin, the bypass scope per axis (tenancy / consent / audit) for each admin endpoint, the audit-row routing rule, and the V3 role-switcher slot.

Options

Bypass-scope axis modelling

  • (B-1) Implicit bypass — admin role is a global "trust me" bit. Simplest. No per-route bypass declaration; admin code paths just call privileged helpers when needed. Loses the regression-test surface; a future admin route silently gains a bypass it shouldn't have.
  • (B-2) Per-axis explicit bypass (chosen). Three orthogonal axes — tenancy, consent, audit — declared per endpoint. The author has to think about each axis at PR time; the ADR is the checklist; the audit-log sink and the chokepoint share-test enforce the answer. Cost: more documentation. Benefit: the bypass surface is enumerable.
  • (B-3) Capability-token model. Admin invokes a capability that bundles the three axes. Over-engineered at our scale (one admin user; three or four bypass shapes total).

Audit-row routing

  • (A-1) One unified audit table for everything. Single sink. Mixes share-grant audit (already in share_audit) with admin-action audit; loses the FADP-defensible separation between "data subject's own consent log" and "platform-action log."
  • (A-2) Two tables, by purpose (chosen). share_audit (existing per ADR-0004 §"Audit") records grant/revoke/shared-read — the consent log surfaced to data subjects under DSAR. admin_audit_log (per #45) records admin-bypass platform actions — visible to admins for forensics, NOT included in client DSAR by default. A given admin action MAY write to both (e.g. an admin force-revokes a share → share_audit.event_kind = grant_revoked AND admin_audit_log.action = share.force_revoke).
  • (A-3) One table with a discriminator. Same as (A-1) with a column. Defeats the FADP-vs-platform separation that's the whole point.

V3 role-switcher mechanism

  • (R-1) Separate cookies / separate logins. Stefan logs out of rbo.wagen.io (admin-mode) and into rbo.wagen.io/me (client-mode) with a different gotrue session. UX hostile.
  • (R-2) URL-segment-driven mode (/admin vs. /me) with a single cookie (chosen). The same JWT session is in scope for both; the chokepoint binds current_stringer_id for /admin paths and current_person_id for /me paths. No claim mutation, no second session, no logout/login. Implementation: middleware reads the URL prefix at the same point it reads the JWT and decides which ContextVar to bind. Stefan-as-both is just one human with one JWT crossing two URL spaces.
  • (R-3) Subdomain-driven mode (admin.rbo.wagen.io vs. me.rbo.wagen.io). Cleaner separation; costs a second TLS cert + Caddy entry + a cross-subdomain cookie scope (Domain=.rbo.wagen.io). ADR-0006 §"Cookie scope" deferred cross-subdomain explicitly. Defer to V3.x if (R-2) proves insufficient.
  • (R-4) Explicit role claim in the JWT. Couples gotrue to RBO's authorization model; rejected for the same reason ADR-0006 rejected JWT-side admin claim.

Decision

Who is admin?

  • V1 / V2. Stefan is the only admin. Stringer.role = StringerRole.admin is set on his row by the migration (Vera, M15) and by the seed for new keystone-platform deployments.
  • V2 (multi-stringer onboarding). Stringer.role = stringer is the default for invite-created stringers (ADR-0001, confirmed in docs/architecture/stringer-lifecycle.md). An admin can promote a stringer to admin via a future admin tool — out of scope for V2; no schema change needed (the column already accepts admin).
  • V3. Same. The role-switcher (below) is for Stefan-as-both, NOT for a new admin role.
  • There is no "super-admin" / "owner" tier. A second admin would have full admin power. This is acceptable at our governance scale; a finer-grained admin model is V4-or-later.

The require_admin chokepoint (single source of truth)

The shared dependency lives at app/auth/admin.py::require_admin (per MR !59). All admin routes — present and future — depend on it:

from app.auth.admin import require_admin

@router.post("/admin/...")
async def admin_route(
    identity: Annotated[BoundIdentity, Depends(require_admin)],
    ...
):
    ...

The dependency layers on require_stringer (ADR-0006 §"current_stringer_id binding"), then re-fetches the Stringer row and 403s if role != admin. Do not duplicate this dependencyroutes_admin_persons.py::_require_admin exists as a Round 4 transitional duplicate (commented in-place) and is scheduled to fold into the shared dependency once Round 5 dust settles. The local copy is allowed temporarily; new admin routes use the shared one.

The dependency does NOT carry the bypass scopes. It only answers "is this caller an admin?" The bypass-scope decisions (next section) are per-route, taken by the handler.

Bypass-scope declaration per admin endpoint

Three orthogonal axes; each endpoint declares yes/no for each:

  • Bypass tenancy — the handler reads or writes rows under stringer IDs other than its own. Mechanism: app.db.tenancy.set_bypass_tenant(True) for the duration of the operation, scoped via the ContextVar token (LIFO; reset on the way out, even on exception). Per ADR-0001 §"Tenancy" bullet 6, every bypass is logged.
  • Bypass consent — the handler accesses Person / ClientProfile / Order data without a grant chain in place (i.e. neither owner, nor Rule #1, nor Rule #2, nor Rule #3 per ADR-0004 §"The three grant types"). The chokepoint allows this only when bypass_tenant=True; documenting it explicitly per route is what tells the FADP DSAR generator that this access falls under "platform admin action" rather than "share-grant access." This is a labelling axis, not a separate code path.
  • Bypass audit — would the action skip writing to admin_audit_log? The answer for every admin endpoint is NO. Every admin action writes an audit row. This axis exists in the table below to make the universal "no" visible (so a future author cannot accidentally normalize "skip audit" as a legitimate option).

Current and planned admin endpoints:

Endpoint Bypasses tenancy? Bypasses consent? Skips audit? Audit destination
POST /admin/stringers/invite No (writes own platform-scoped Stringer row) No No admin_audit_log (action=stringer.invite)
POST /admin/stringers/{id}/finalize Yes (cascades across the offboarding stringer's data) Yes (FADP scrubs Person/ClientProfile fields) No admin_audit_log (action=stringer.finalize)
POST /admin/catalogue/rackets/{id}/promote Yes (reads any stringer's pending submission) No (catalogue rows carry no consent semantics) No admin_audit_log (action=catalogue.racket.promote)
POST /admin/catalogue/rackets/{id}/reject Yes (same) No No admin_audit_log (action=catalogue.racket.reject)
POST /admin/catalogue/strings/{id}/promote Yes No No admin_audit_log (action=catalogue.string.promote)
POST /admin/catalogue/strings/{id}/reject Yes No No admin_audit_log (action=catalogue.string.reject)
POST /admin/persons/finalize-expired Yes (cascades across every tenant the Person touches) Yes (FADP hard-erase) No admin_audit_log (action=person.finalize_expired; on F-2 cascade rollback the same row is written with action=person.finalize_expired.failed and metadata.error) AND share_audit (event_kind=person_erasure per R-FADP-7)
POST /admin/persons/{id}/merge (planned, partly shipped Round 10 — share_audit emission wired; admin_audit_log dual-write wired by #161) Yes (rewrites FKs across stringers) Yes (touches Person rows the admin has no grant for) No admin_audit_log (action=person.merge) AND share_audit (event_kind=person_merge per Iris R-FADP-7)
DSAR queue review (planned, V2.x) Yes (reads target Person's data across tenants) Yes (DSAR is the legal basis, not a grant) No admin_audit_log (action=dsar.{access\|portability\|erasure}) AND dsar_log (per fadp-posture.md)
POST /admin/api/v1-upload (Stage 1: stage XLSX; wired by #159) Yes (the upload writes rows for every stringer in the XLSX) Yes (touches Person/ClientProfile rows the admin has no grant for) No admin_audit_log (action=v1_upload.stage, target_type=v1_upload_run)
GET /admin/api/v1-upload/{run_id}/dry-run (Stage 2: dry-run; wired by #159) Yes (the dry-run scans XLSX rows for every stringer) Yes (reads Person/ClientProfile state across tenants) No admin_audit_log (action=v1_upload.dry_run, target_type=v1_upload_run)
POST /admin/api/v1-upload/{run_id}/approve (Stage 3: commit; wired by #159) Yes (cascades writes across every stringer in the XLSX) Yes (writes Person/ClientProfile rows across tenants) No admin_audit_log (action=v1_upload.approve, target_type=v1_upload_run)
POST /admin/api/v1-upload/{run_id}/cancel (discard pending run; wired by #159) No (only mutates the run row itself) No (no Person/ClientProfile access) No admin_audit_log (action=v1_upload.cancel, target_type=v1_upload_run)
GET /admin/stringers/{stringer_id}/export.xlsx (admin export-as-someone-else, XLSX branch; wired by #160) Yes (admin reads another stringer's Orders/Players/Rackets/Strings) No (per-stringer business data, not Person/ClientProfile access without a grant) No admin_audit_log (action=stringer.export_as, target_type=stringer, metadata.format=xlsx)
GET /admin/stringers/{stringer_id}/export.json (admin export-as-someone-else, JSON branch; wired by #160) Yes (same load shape as the XLSX branch) No No admin_audit_log (action=stringer.export_as, target_type=stringer, metadata.format=json)
POST /admin/catalogue/imports/{id}/promote (catalogue-import promote; wired by #166, Phase 1 of #164) Yes (writes a new catalogue_shared Racket/String row across all stringers) No (catalogue rows carry no consent semantics) No admin_audit_log (action=catalogue.import.promote, target_type=catalogue_import, metadata.new_row_id)
POST /admin/catalogue/imports/{id}/reject (catalogue-import reject; wired by #166) Yes (reads any importer's pending row) No No admin_audit_log (action=catalogue.import.reject, target_type=catalogue_import, reason=admin-supplied reject reason)
POST /admin/catalogue/imports/{id}/match (catalogue-import link-to-existing; wired by #166) Yes (reads + links across stringer-boundary catalogue rows) No No admin_audit_log (action=catalogue.import.match, target_type=catalogue_import, metadata.matched_id)

Reading the table: if a row says "Bypasses tenancy: Yes," the handler MUST wrap its data access in set_bypass_tenant(True) with a try/finally to reset. If it says "Bypasses consent: Yes," the corresponding admin_audit_log.metadata payload MUST record the affected person_id(s) so the DSAR generator can later reconstruct the access. Both columns are tested — one regression test per row asserts the audit row gets written when the route fires (see Required tests).

Audit-row routing rule

Two tables, by purpose:

  • share_audit (per ADR-0004 §"Audit") — the data-subject-facing consent log. Records grant_created, grant_revoked, shared_read, plus FADP-related extensions (person_erasure, person_merge, consent_change per Iris R-FADP-7). Surfaced in DSAR.
  • admin_audit_log (per #45) — the platform-internal forensic log. Records every admin-bypass platform action. Schema per #45: id, occurred_at, actor_id (Stringer.id of the admin, or NULL for platform-initiated actions per the keystone#160 amendment below), action (e.g. catalogue.racket.promote), target_type (order, stringer, string, …), target_id, reason (optional admin-supplied note), metadata (JSONB; before/after, request context). NOT surfaced in DSAR by default — it is metadata about the platform's actions, not the data subject's data.

Composite case. When an admin action affects both axes (e.g. an admin force-revokes a share, or runs a Person merge), the handler writes to both tables in the same transaction. The two rows share a request_id (same UUID share_audit already carries; admin_audit_log.metadata.request_id) so a forensic query can join them. This is the only place the two tables touch.

Append-only. Both tables are append-only by convention (per ADR-0004 §"Audit"); future hardening can add a Postgres trigger refusing UPDATE/DELETE. No admin route ever DELETEs an audit row — including a Person merge or scrub. The admin_audit_log row for action=person.scrub survives the scrub it records (the row has action, target_id, actor_id — none of those are PII; the metadata field intentionally does NOT capture the scrubbed PII).

Platform-initiated audit rows (amendment, keystone#160)

Audit rows MAY have a NULL actor_id to record platform-initiated / system-actor actions where no human admin clicked a button. The first consumer is the keystone-management onboarding hook: keystone#160 adds POST /admin/stringers/from-keystone, a service-role-authenticated endpoint that materialises a Stringer row after keystone-management has created the corresponding gotrue user. There is no Stringer behind that request — the caller is a service identity in another app, not a logged-in human — so the audit row writes actor_id = NULL and records meta.via_keystone = true as the slice key. The existing per-human-admin rows retain a real actor_id; the nullable shape is purely additive on the callers.

Service-role JWT shape (hotfix amendment). The bearer token this endpoint accepts is the keystone platform's canonical service-role JWT — {role: "service_role", iat, exp} — emitted by manage-user.sh on kst1 and mintAdminJwt in keystone-management. The first cut of the endpoint reused the cookie-driven user-session verifier (verify_access_token) which enforces iss/aud/sub (the gotrue user-token contract); every production hook call failed verification with 401 because the platform mint omits all three. The shipped fix introduces a sibling verifier (app/auth/jwt.py::verify_service_role_token) that enforces the minimal {role, iat, exp} claim set + HS256 signature only. The cookie path's strict verifier is unchanged. Consequence: ServiceRolePrincipal.sub is str | None (the production token shape carries no sub); the audit row uses actor_id = NULL regardless of whether the bearer carried a sub, so the sub value is informational only.

Why nullable rather than a sentinel "system" Stringer row: the in-tree precedent already exists (share_audit.actor_id has shipped nullable since V2), and adding a tombstone tenant row just to satisfy a NOT NULL constraint would leak a platform-internal fiction into the tenant-root table. The schema change lands in Alembic 0015_admin_audit_actor_nullable (one ALTER COLUMN, dropping NOT NULL). Forensic queries that paginate by actor (WHERE actor_id = :id) are unaffected — a NULL actor row is simply outside the filter; the index on (actor_id, occurred_at) admits NULLs cleanly.

The action vocabulary is unchanged. Platform-initiated onboarding through /admin/stringers/from-keystone re-uses AdminAuditAction.stringer_invite (not a new member) so a forensic query for "every onboarding event" returns both human-admin and keystone-initiated paths in one scan; meta.via_keystone is the slice key when the two paths must be separated.

Operational notes (amendments from finalize-expired spec, #158)

These notes refine the bypass-scope table without re-opening the decisions above. Surfaced by #156 / MR !108 while writing docs/design/admin-finalize-expired.md.

  • Cascade-failure variant. person.finalize_expired.failed is a sibling action of person.finalize_expired written on F-2 (cascade rollback). The bypass axes are identical to the success row — the failed attempt is a logged no-op, not a separate authorization decision. metadata.error carries a truncated message; no share_audit row accompanies the failure (the transaction rolled back so the person_erasure row is also absent). Required tests #5 + #6 cover only the success row; the failure path has its own one-line write test (cascade rollback path).
  • Endpoint shape for POST /admin/persons/finalize-expired. The shipped endpoint (app/api/routes_admin_persons.py::finalize_expired) is bulk-cohort today: it scans every eligible Person and runs _finalize_one per row in a per-Person transaction (A-FADP-5.3). The finalize-expired UX spec assumes singleton-capable invocation (one Person per HTTP call, via a ?person_id= query param) for the typed-confirm Stage-2 flow. The endpoint extension is small (one optional query param + a one-line if person_id branch in the candidate-scan); tracked as OQ-FE-1 in the spec. Note for future readers: if the UX lands before the endpoint extension, Juno's Stage-2 submit issues a bulk call and the server scopes the cascade to the single ID via post-hoc filtering — not the preferred shape but a graceful interim.
  • Person-creation provenance is V3. The finalize-expired UX surfaces Person.created_by_kind / Person.created_by_id (A-CONS-1) on Stage-2's subject card. These columns are a V3 schema ask per fadp-posture § Schema asks for Pax/Theo — they may not exist at V2 implementation time. The UX degrades cleanly: Stage 2 renders "Created {date}" without the "by stringer {name}" sub-clause if the columns are absent. The ADR does not require provenance for any admin route to function; it is a UX-quality hint.
  • Pre-scrub display-name retention is a known cleartext field. admin_audit_log.metadata.pre_scrub_display_name retains the Person's display name across the hard-erase cascade (Stage 3 of the finalize-expired UX renders it one last time for closure). This is cleartext PII stored in the platform-internal forensic log after the data-subject's row has been scrubbed. It is intentional and bounded:
  • It lives only in admin_audit_log (NOT share_audit), so it is NOT surfaced in client DSAR by default (an erased subject's DSAR cannot contain their own pre-scrub display name; that would be a contradiction).
  • It supports the admin's "yes, this was the right Person" forensic beat — a use case the audit-only IDs cannot fulfil.
  • Retention is implicit-forever (append-only audit log); if a future "scrub the admin_audit_log too" obligation arises, this field would be the first scrubbed.
  • Privacy trade-off: the admin keeps a name reference; the data subject's DSAR does not. Acceptable under FADP because the admin_audit_log is platform-action metadata, not data-subject data.

Pax-side flag: the field name is normative (pre_scrub_display_name); do not rename without amending this ADR. Spec cross-ref: admin-finalize-expired OQ-FE-3. - Concurrent finalize race semantics. Two races exist: 1. Singleton-vs-batch. Stefan opens the finalize-expired UX (Stage 2 for Person X) at t0; a sibling browser tab or a future cron triggers the bulk endpoint at t1; both target Person X. The per-Person transaction isolation (A-FADP-5.3) means whichever transaction commits second sees an already-scrubbed Person (NULL display_first_name, scrubbed_at IS NOT NULL) and _finalize_one is idempotent — it returns without re-emitting an audit row. The losing UI surfaces a friendly "Already finalized by another action" message via the F-3 path (eligibility re-query on Stage-2 render; see spec OQ-FE-9). 2. Cascade-vs-DSAR-export. A DSAR export running concurrently with finalize-expired reads via the bypass-tenant + bypass-consent path; the cascade write transaction has its own isolation. Worst case: the export captures the pre-scrub PII (timing-correct under FADP — the export was started before erasure); best case: the export captures the post-scrub state. No locking is added; both outcomes are FADP-defensible. Test-coverage expectation: integration tests cover (1) idempotency on second call; (2) F-3 eligibility re-check returns 409 or empty after concurrent scrub. The export race (2) is not exercised in tests (timing-dependent; documented as accepted). - person_merge dual-write contract. Per the bypass-scope table row, Person merge writes to both share_audit (event_kind=person_merge, the data-subject-facing consent-log entry) AND admin_audit_log (action=person.merge, the platform-internal forensic entry) in the same transaction with a shared request_id. This is the same composite-case pattern as finalize-expired. Implementation history: Round 10 (#157, MR !109) wired the share_audit side; Round 11 (#161) wires the admin_audit_log side and the cross-table request_id join. The dual-write is intentional and not redundant — share_audit is what the data subjects (both surviving and merged Person) see under DSAR; admin_audit_log is the forensic record of the admin's action (which share_audit alone does not fully capture — e.g. the reason field and the actor's admin-mode context).

V3 role-switcher (architectural slot)

The slot. A single human (Stefan) has one gotrue_user_id that is bound to both a Stringer row (his admin row) and a Person row (when another stringer adds him as a client, or when he tests-as-client via the V3 portal). ADR-0006 §"Resolution precedence" resolves Stringer-first in V2; the slot for V3 is the explicit role-switcher.

Mechanism (chosen Option R-2, URL-segment-driven).

  • One JWT cookie, one session. No second login. The same rbo_session cookie covers both modes.
  • Two URL spaces. /admin/... and other stringer-facing routes resolve current_stringer_id from the JWT's sub. /me/... (V3 client portal) routes resolve current_person_id from the same sub. The middleware decides which ContextVar to bind based on the URL prefix, not on a JWT claim.
  • Chokepoint behavior. With current_stringer_id bound, the chokepoint applies the stringer-scoped predicate (per ADR-0001/0004). With current_person_id bound, the chokepoint applies the Person-scoped predicate (per ADR-0004 §"Authorization predicate"). The chokepoint refuses if neither is bound on a tenant-scoped query.
  • Stefan-as-both navigation. Stefan clicks "View as client" in his admin UI → the link is a /me/... URL. The cookie is the same; only the URL prefix changes. He clicks "Back to admin" → /admin/.... No re-auth.
  • Cross-mode linkbacks are explicit. A /me/... page shows a banner "You are viewing as a client. Switch to admin." The banner only shows for users whose gotrue_user_id resolves to BOTH a Stringer AND a Person. Iris owns the UX copy.

What this slot does NOT do.

  • It does not let a non-admin Stringer become an admin by URL (the Stringer.role check in require_admin still gates /admin/*).
  • It does not let a Stringer access another stringer's data via /me (the Person-scoped predicate is bound to current_person_id, which resolves to the Stringer's own Person — they only see their own client view).
  • It does not change ADR-0006's wire-level contract: claim shape, cookie attributes, refresh, logout — all unchanged.

Why URL-segment, not subdomain. Subdomain-driven (R-3) needs cross-subdomain cookies (Domain=.rbo.wagen.io), a second Caddy entry, and a second TLS cert. URL-segment is one Caddy site, one cookie, one cert. Re-evaluate if the V3 client portal grows into something that wants its own brand identity (e.g. clients.wagen.io); at that point a separate ADR amends this.

Why URL-segment, not a JWT claim. Adding role: stringer | person to the JWT couples the gotrue ↔ RBO contract to RBO's authorization model — the same coupling ADR-0006 explicitly rejected. URL-segment keeps the boundary at the middleware where it belongs.

What this ADR does NOT cover

  • admin_audit_log table schema — owned by #45; this ADR commits to the existence of the table and the routing rule, not the column-level shape (which is already drafted in #45).
  • DSAR generation logic — owned by Iris's fadp-posture.md and the future R-FADP-3 implementation; this ADR commits to "share_audit is in DSAR; admin_audit_log is not" but does not enumerate the DSAR JSON shape.
  • Person merge UX — owned by docs/design/admin-person-merge.md; this ADR slots its audit destination only.
  • Catalogue moderation UX — owned by Mira / Iris; this ADR slots its audit destination only.
  • Future role-tier expansions (super-admin, read-only admin, etc.) — out of scope; would supersede this ADR when needed.

Required tests (this ADR mandates them)

  1. Universal require_admin test. Every admin route refuses a non-admin Stringer with 403. Iterates over the registered admin routes via FastAPI's app.routes; failing to attach the dependency to a new admin route fails this test.
  2. require_admin with no JWT → 401. The dependency layers on require_stringer, so the upstream 401 path is preserved; smoke-tested per route.
  3. Bypass-tenancy declaration test. For every admin route in the table above with "Bypasses tenancy: Yes," assert the route enters set_bypass_tenant(True) (test fixture instruments the ContextVar). Failing to wrap the route in set_bypass_tenant is detected.
  4. Bypass-tenancy reset test. Per route, assert that on exception inside the handler, bypass_tenant is reset (the try/finally is correct). One failed test fixture per route.
  5. Audit-row write test. For every admin route, assert that a successful call writes exactly one row to admin_audit_log with the documented action value. Composite-case routes (Person merge, finalize-expired) additionally assert a share_audit row.
  6. Composite-case request_id join test. For composite-case routes, assert the share_audit.request_id and admin_audit_log.metadata.request_id are equal (the cross-table join is exercisable).
  7. Append-only test (admin_audit_log). Attempting UPDATE or DELETE on admin_audit_log from a normal session is refused (test layer; future hardening adds a trigger).
  8. V3 role-switcher slot test (deferred to V3 phase 1). A single fixture user with both a Stringer row and a Person row hits /admin/whoami and /me/whoami from the same JWT cookie; both resolve correctly. Skipped under V2 (no /me/* routes yet); the test name is reserved.

Consequences

Good

  • One enumerable admin surface. A future admin route is a one-row addition to the bypass-scope table + a reuse of require_admin + an audit destination. The PR template references this ADR.
  • Audit boundary is FADP-defensible. share_audit is what the data subject sees under DSAR; admin_audit_log is platform forensics. The two-table separation is a structural answer to the "what does the client see vs. what does the platform retain" question.
  • require_admin stays trivial. It is the role gate; bypass scopes are per-route. New admin routes do not bloat the dependency.
  • V3 role-switcher slot is reserved without code. The chokepoint already binds current_stringer_id OR current_person_id (per ADR-0006). The V3 work is just the URL routing + the banner UX. No middleware rewrite, no cookie-scope amendment.
  • Stefan-as-both is a trivial UX problem, not an auth-protocol problem. No JWT mutation, no second login, no subdomain split. The same JWT serves both URL spaces.

Costs we accept

  • The bypass-scope table will drift if not maintained. Mitigation: test #1 + test #5 enumerate routes via the FastAPI app and require the audit-row sink to be wired. A new admin route without a row in the table fails CI by virtue of failing test #5.
  • Two audit tables instead of one. Costs one JOIN on the rare forensic query that needs both. Worth it for the FADP-defensible separation.
  • Per-route bypass declaration is one more PR-time decision. Mitigation: the table is the checklist; the PR template references this ADR.
  • The role-switcher slot is reserved but not built. V3 work has to land it; the architectural commitment here is that no V2 decision blocks the work. If V3 discovers that URL-segment is insufficient (e.g. brand-identity reasons), Option R-3 (subdomain) is still on the table — that requires a new ADR amending this one.
  • Stringer.role = admin is an implicit single-tier model. A future "promote to read-only admin" feature would need a finer-grained role enum. Acceptable; out of V2 scope.
  • The temporary duplicate _require_admin in routes_admin_persons.py is a rough edge. Tracked: fold into shared dependency once Round 5 routes land.

Alternatives considered (and why not)

  • JWT-side admin claim. Rejected — same coupling ADR-0006 rejected. The JWT is identity; authorization is RBO's domain.
  • Capability-token model (B-3). Over-engineered; one human is the admin.
  • Single audit table (A-1 / A-3). Defeats the FADP-vs-platform separation that's the whole point of #45.
  • Subdomain-driven role-switcher (R-3). Defer; URL-segment handles V3 cleanly with one cert and one cookie.

Cross-references

  • ADR-0001 — names bypass_tenant as the admin escape; this ADR enumerates which routes use it.
  • ADR-0004share_audit table; this ADR commits to it as the data-subject consent log.
  • ADR-0006 — admin role is a Stringer-row attribute, not a JWT claim; this ADR is the operational consequence.
  • app/auth/admin.py — the require_admin dependency.
  • app/db/tenancy.pyset_bypass_tenant / reset_bypass_tenant ContextVar machinery.
  • 45 — admin_audit_log table (forward reference; routing rule lands here).

  • 122 — this ADR.

  • docs/design/admin-person-merge.md — Person-merge admin tool; audit destination defined here.
  • docs/design/admin-finalize-expired.md — finalize-expired UX spec (#156); source of the operational-notes amendments above.
  • docs/requirements/fadp-implementation-asks.md — R-FADP-5 (finalize-expired), R-FADP-7 (share_audit event-kind extensions, canonical name person_erasure).
  • docs/architecture/auth-and-tenancy.md — chokepoint mechanics (the V3 role-switcher slot extends the existing ContextVar pair).
  • 158 — seven amendments folded into this ADR (person_erasedperson_erasure typo, finalize_expired.failed variant, endpoint-shape note, provenance degradation, pre-scrub-name retention, race semantics, merge dual-write).

  • 161 — wires admin_audit_log emission on the Person-merge route to complete the dual-write contract.

  • 159 — extends the bypass-scope table to the V1-upload admin endpoints (v1_upload.stage, v1_upload.dry_run, v1_upload.approve, v1_upload.cancel) and wires admin_audit_log emission against the new v1_upload_run target type.

  • 160 — extends the bypass-scope table to the admin export-as-someone-else endpoints (GET /admin/stringers/{id}/export.{xlsx,json}, action=stringer.export_as, target_type=stringer); reuses the self-service row-flatteners while keeping the route handler separate per the "separation is the contract" rule in app/api/routes_export.py's module docstring.

  • wagen/keystone#160 — keystone-management onboarding hook; adds POST /admin/stringers/from-keystone (service-role JWT, no require_admin) and the "Platform-initiated audit rows" amendment above (NULL actor_id for system-actor rows; Alembic 0015_admin_audit_actor_nullable).