ADR-0011: Admin role boundaries¶
- Status: Accepted (flipped from Proposed on 2026-05-11 when #45 landed the
admin_audit_logtable + 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:
- 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. - 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 plansadmin_audit_logfor exactly this case but the contract — when to write to which table — is undocumented. - 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_revokedANDadmin_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 intorbo.wagen.io/me(client-mode) with a different gotrue session. UX hostile. - (R-2) URL-segment-driven mode (
/adminvs./me) with a single cookie (chosen). The same JWT session is in scope for both; the chokepoint bindscurrent_stringer_idfor/adminpaths andcurrent_person_idfor/mepaths. 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 whichContextVarto bind. Stefan-as-both is just one human with one JWT crossing two URL spaces. - (R-3) Subdomain-driven mode (
admin.rbo.wagen.iovs.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.adminis set on his row by the migration (Vera, M15) and by the seed for new keystone-platform deployments. - V2 (multi-stringer onboarding).
Stringer.role = stringeris the default for invite-created stringers (ADR-0001, confirmed indocs/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 acceptsadmin). - 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 dependency — routes_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.idof 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.failedis a sibling action ofperson.finalize_expiredwritten 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.errorcarries a truncated message; noshare_auditrow accompanies the failure (the transaction rolled back so theperson_erasurerow 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_oneper 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-lineif person_idbranch 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_nameretains 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(NOTshare_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_sessioncookie covers both modes. - Two URL spaces.
/admin/...and other stringer-facing routes resolvecurrent_stringer_idfrom the JWT'ssub./me/...(V3 client portal) routes resolvecurrent_person_idfrom the samesub. The middleware decides which ContextVar to bind based on the URL prefix, not on a JWT claim. - Chokepoint behavior. With
current_stringer_idbound, the chokepoint applies the stringer-scoped predicate (per ADR-0001/0004). Withcurrent_person_idbound, 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 whosegotrue_user_idresolves 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.rolecheck inrequire_adminstill gates/admin/*). - It does not let a Stringer access another stringer's data via
/me(the Person-scoped predicate is bound tocurrent_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_logtable 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)¶
- Universal
require_admintest. Every admin route refuses a non-admin Stringer with 403. Iterates over the registered admin routes via FastAPI'sapp.routes; failing to attach the dependency to a new admin route fails this test. require_adminwith no JWT → 401. The dependency layers onrequire_stringer, so the upstream 401 path is preserved; smoke-tested per route.- 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 inset_bypass_tenantis detected. - Bypass-tenancy reset test. Per route, assert that on exception inside the handler,
bypass_tenantis reset (thetry/finallyis correct). One failed test fixture per route. - Audit-row write test. For every admin route, assert that a successful call writes exactly one row to
admin_audit_logwith the documentedactionvalue. Composite-case routes (Person merge, finalize-expired) additionally assert ashare_auditrow. - Composite-case
request_idjoin test. For composite-case routes, assert theshare_audit.request_idandadmin_audit_log.metadata.request_idare equal (the cross-table join is exercisable). - Append-only test (admin_audit_log). Attempting
UPDATEorDELETEonadmin_audit_logfrom a normal session is refused (test layer; future hardening adds a trigger). - 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/whoamiand/me/whoamifrom 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_auditis what the data subject sees under DSAR;admin_audit_logis 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_adminstays 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_idORcurrent_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 = adminis 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_admininroutes_admin_persons.pyis 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_tenantas the admin escape; this ADR enumerates which routes use it. - ADR-0004 —
share_audittable; 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— therequire_admindependency.app/db/tenancy.py—set_bypass_tenant/reset_bypass_tenantContextVar machinery.-
45 —
admin_audit_logtable (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_auditevent-kind extensions, canonical nameperson_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_erased→person_erasuretypo,finalize_expired.failedvariant, endpoint-shape note, provenance degradation, pre-scrub-name retention, race semantics, merge dual-write).¶ -
161 — wires
admin_audit_logemission 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 wiresadmin_audit_logemission against the newv1_upload_runtarget 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 inapp/api/routes_export.py's module docstring.¶ - wagen/keystone#160 — keystone-management onboarding hook; adds
POST /admin/stringers/from-keystone(service-role JWT, norequire_admin) and the "Platform-initiated audit rows" amendment above (NULLactor_idfor system-actor rows; Alembic0015_admin_audit_actor_nullable).