Skip to content

Admin role tiers: file-rooted superadmins + runtime-managed roster (P2B)#51

Merged
MartinMontero merged 4 commits into
mainfrom
claude/admin-role-tiers-oux5ar
Jul 5, 2026
Merged

Admin role tiers: file-rooted superadmins + runtime-managed roster (P2B)#51
MartinMontero merged 4 commits into
mainfrom
claude/admin-role-tiers-oux5ar

Conversation

@MartinMontero

Copy link
Copy Markdown
Owner

What does this PR do?

Extends the landed Phase-2 admin auth (worker/admin/*) with the P2B two-tier role model: the committed allowlist (worker/admin/allowlist.ts) is the file-rooted tier (PR-governed, immutable at runtime); a new D1 admin_roster in the existing DB holds runtime-managed admins, mutable only by file superadmins via three new superadmin-only /api/admin/admins* endpoints, with every mutation written to an insert-only admin_audit. One resolver (worker/admin/roles.ts) re-derives the role from the current effective set (file ∪ roster) on every authenticated request — cookie path included — so removal is effective on the principal's next request. Four commits, one phase each: migration → resolver/rewiring → management API → invariant tests.

Phase 0 stale-brief corrections (on the record)

  • The per-request cookie re-check the brief flagged as missing had already landed on main via PR fix(admin): per-request allowlist enforcement on the cookie path (revocation takes effect next request) #49 (resolveAllowlistedAdmin); this PR generalizes it to role re-derivation (resolveRoledAdmin) and extends it to logout, which was still resolving sessions without a membership re-check — that gap is closed here (a valid session id reaching logout is now always destroyed; membership failure changes the status code, never leaves the record alive).
  • The allowlist does not ship empty: the owner identities were constituted via PR Admin constitution — genesis role schema + the two owner identities (superadmin) #50. All fails-closed proofs remain fixture-based (injected empty allowlist), so they hold regardless of the committed set.
  • No prior "input-normalization fix" exists anywhere in the tree or history; normalizeSubject introduced here (allowlist.ts) is therefore THE single shared Cf-stripping normalizer, per the one-implementation ruling.

The four rulings (applied)

  1. File role: 'admin' entries are honored as recorded — they log in like admins, hold no roster-mutation rights, and are immutable via the API (add → 409, remove → 403). The file is a complete governance surface: a fork may run its entire admin set through PRs with the roster empty forever.
  2. Allowlist exports additive onlynormalizeSubject (Unicode trim + strip all Cf format chars + lowercase hex; DIDs exact after strip) and isValidSubject (the same HEX_PUBKEY/DID rules the matchers enforce). Existing exports byte-untouched. npub input is accepted on the management API via the in-tree nostr-tools/nip19 and the hex is echoed back. Validation errors surface only after successful superadmin auth — no pre-auth format oracle.
  3. Actor/audit format provider:subject.
  4. Remove of a non-member roster entry → 404.

D1 availability asymmetry (deliberate)

On the auth path a missing/unreachable DB treats the roster as empty: file principals keep working (a D1 outage can't lock the operator out), roster admins are denied — fails closed, never degraded-open. The management routes do the opposite: DB unavailability is an explicit 503 (roster unavailable), and a file-only list is never rendered as if complete.

Migration sequencing note

Between merge (code deploys) and the remote migration apply, the management routes return 503 and existing auth is unaffected — fails closed by design. Remote apply is a hard stop reserved to the owner (see below).

What the owner does post-merge

  1. Apply the migration remotely (hard stop — exact command, run only on your explicit go):
    npx wrangler d1 migrations apply wcjbt-auth --remote
  2. Positive round-trip with a throwaway public identifier only (an npub/hex pubkey or DID — never any secret): POST /api/admin/admins/add with {"provider":"nostr","subject":"<throwaway npub or hex>","note":"round-trip test"} (cookie + x-admin-csrf, or per-request NIP-98), confirm it appears in GET /api/admin/admins as source: roster, then POST /api/admin/admins/remove for the same principal.
  3. Quote the two audit rows:
    npx wrangler d1 execute wcjbt-auth --remote --command "SELECT at, actor, action, provider, subject, method, note FROM admin_audit ORDER BY at;" — expect one admin.add and one admin.remove for the throwaway subject, actor provider:subject of the acting superadmin.

Type of change

  • New catalog entry
  • Fix/update to an existing entry
  • New or updated recipe
  • Build flow / docs
  • Enforcement engine / tooling
  • Other

Required checks

The enforcement engine is blocking. CI runs all of this on your PR, but please
confirm you ran it locally too.

  • npm run check passes (schema validation)
  • npm run enforce passes (three-layer exclusion policy + recipe contract)
  • npm test passes (315/315 — 291 baseline + 24 new invariant tests, each P2B invariant pinned by name)
  • npm run build succeeds (npm run verify:all exit 0)

Dependency delta: zero (package.json/lockfile untouched; nip19 comes from the already-in-tree nostr-tools). Migration applies cleanly on a fresh local D1 (0001 → 0002 verified from empty state). No workflow files touched; operational_advisory untouched.

For catalog entries

Not applicable — no catalog entries in this PR.

For recipes

Not applicable — no recipes in this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc


Generated by Claude Code

claude added 4 commits July 5, 2026 17:03
…n_audit

Two-tier schema: the committed allowlist stays the file-rooted tier (PR-
governed, immutable at runtime); admin_roster holds the runtime-managed
ADMIN tier, mutable only by file superadmins via /api/admin/admins (Phase 3);
admin_audit records every mutation and is insert-only by contract. Applied
locally via migrate:local; remote apply is deliberately deferred (management
routes 503 between code deploy and remote apply — fails closed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc
… tiers)

resolveAdminRole (worker/admin/roles.ts) is now the ONE authority every
authenticated admin route authorizes through: the committed file first (role
honored as recorded — only file 'superadmin' entries will hold roster-mutation
rights; the file always wins), then the D1 admin_roster ('admin' only — the
roster can never yield superadmin). DB absent/unreachable ⇒ roster treated as
EMPTY on this auth path: file principals unaffected, roster admins denied —
fails closed, never degraded-open.

Rewired login/verify, elevate, whoami (both paths), and logout — closing the
landed gap where logout resolved the session without a membership re-check.
A valid session id reaching logout is now ALWAYS destroyed; membership
failure changes the status code, never leaves the record alive.

allowlist.ts: header contract updated to the two-tier model; two additive
exports (normalizeSubject — Unicode trim + strip all Cf format chars +
lowercase hex, DIDs exact; isValidSubject — the same HEX_PUBKEY/DID rules the
matchers enforce). Existing exports untouched; one validator, not two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc
Three exact-path routes (deny-by-default preserved): GET /api/admin/admins
(file principals marked immutable ∪ D1 roster — DB failure is an explicit
503, never a file-only list served as complete), POST /api/admin/admins/add
(409 file-resident or duplicate), POST /api/admin/admins/remove (403
file-resident, 404 unknown, best-effort purge of the removed admin's live
sessions — the per-request role re-derivation stays authoritative).

One auth gate (requireSuperadmin): cookie session with per-request role
re-derivation + coordinator CSRF on mutations, or per-request NIP-98 with
single-use event ids (Nostr superadmins; Bluesky superadmins manage via
cookie+CSRF only). Non-superadmins get the same generic 401 — no role
oracle; target validation runs only after successful auth — no pre-auth
format oracle. Targets go through the SHARED normalizer/validator
(allowlist.ts), npub input decoded via the in-tree nostr-tools nip19 with
hex echoed back; notes reuse sanitizeDisplayName. Every mutation writes the
roster row and its admin_audit row ('provider:subject' actor, cookie|nip98
method) in one atomic batch; audit is INSERT-only — no UPDATE/DELETE path
exists. New AUTH_RATE_LIMITER bucket 'admin-manage' mirrors the existing
admin buckets. KVNamespace deliberately widened with cursor-paged list()
for the purge; all test fakes extended to match. Dependency delta: zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc
24 tests in worker/tests/admin-roster.test.ts, same harness discipline (real
schnorr, real AdminCoordinator behind hand fakes, DI'd deps): I1 runtime can
never grant/revoke superadmin (file-resident add 409 / remove 403, nothing
audited; roster yields only 'admin'; Ruling 1 — a FILE admin logs in but is
refused management with the same generic 401); I2 fails closed (empty file
rejects management even with a valid signature; DB absent/unreachable ⇒
roster empty on auth — superadmins work, roster admins don't — and an
explicit 503 on management, never a file-only list); I3 per-request role
re-derivation cookie path included (data-layer removal kills the live
session next request; route removal purges KV immediately; purge failure
tolerated; logout destroy-on-null); I4 insert-only audit pinned statically
(no UPDATE/DELETE against admin_audit in any worker module); I5 the ONE
shared normalizer/validator incl. the U+202A bidi case. Plus: exact audit
rows per auth path, npub input echoed as hex, no pre-auth format oracle,
CSRF + cross-site on cookie mutations, NIP-98 payload-tag binding + replay
burn on management calls, Bluesky superadmin via cookie+CSRF, admin-manage
429s, deny-by-default 404s around the new surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
wecanjustbuildthings 9c23253 Jul 05 2026, 05:27 PM

@MartinMontero
MartinMontero marked this pull request as ready for review July 5, 2026 18:47
@MartinMontero
MartinMontero merged commit a589809 into main Jul 5, 2026
14 checks passed
@MartinMontero
MartinMontero deleted the claude/admin-role-tiers-oux5ar branch July 5, 2026 18:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants