You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[Admin] Foundation: access control gating + admin-action audit log
Why
requireAdmin() (workers/api/src/lib/auth.ts) already checks session.roles.includes("admin") but is wired to no routes. Before any admin API ships we need: (a) a documented, tested way to grant/verify the admin role, (b) a lightweight probe the admin UI can call, and (c) an audit log so every privileged mutation is recorded (a gap PAS explicitly still has).
Scope
Add GET /v1/admin/me (or /v1/me/is-admin) → { admin: boolean }, behind requireUser (not requireAdmin, so non-admins get false not 403). The UI uses this to decide whether to mount the portal.
Confirm/derive admin role: users.roles JSON column, read at sign-in (routes/auth.tsupsertUser). Provide a safe way to grant admin — a migration seeding the operator account (serge.the.dev@gmail.com / operator GitHub id) as admin, plus a documented ADMIN_ALLOWLIST env fallback (comma-separated user ids or logins) checked inside requireAdmin so the very first admin isn't a chicken-and-egg problem.
New admin_audit_log table (migration): id, actor_user_id, action, target_type, target_id, detail_json, created_at. Add a recordAdminAction(env, session, action, target, detail) helper in lib/.
Wire the helper into a shared admin route middleware so every mutating /v1/admin/* call is logged.
Acceptance criteria
GET /v1/admin/me returns correct {admin} for admin and non-admin sessions.
requireAdmin honors both users.roles and the ADMIN_ALLOWLIST env fallback; unit-tested for allow + 403 deny.
Migration seeds the operator as admin and is idempotent.
admin_audit_log migration + helper; a mutating admin call writes exactly one row with actor + target + action.
Non-admin hitting any /v1/admin/* mutating route → 403 and no audit row.
FAS requireAdmin via ADMIN_GITHUB_LOGINS/ADMIN_USER_IDS (fas/platform/packages/backend/src/lib/auth.ts)
PAS dual model + its missing audit trail (pas/.../submissions-helpers.ts)
Part of the PAGS Admin Portal epic. Blocks all other admin issues.
Best-practice hardening (2026-08-01)
Defense-in-depth auth (not role-claim alone). A portal with read access to every tenant must not be guarded only by an HS256 role claim. Add a Cloudflare Access perimeter in front of both the /admin UI and /v1/admin/* API: a cloudflareAccessGate() middleware verifies the Cf-Access-Jwt-Assertion header against the team JWKS (RS256), inert until CF_ACCESS_TEAM_DOMAIN + CF_ACCESS_AUD are set (same pattern as FAS verifyAccessJwt). Role check runs behind it. Document turning CF Access on for admin.*//admin before real onboarding.
Live role resolution (no stale-token lockout / no re-login lag).requireAdmin resolves admin from: session role → live users.roles DB read → ADMIN_ALLOWLIST env (break-glass, by uid). So a freshly-granted admin ([Admin][API] Moderation — suspend user, unpublish/delete agent, cancel instance, roles/keys #34) takes effect immediately and a role revocation isn't defeated by an old token.
[Admin] Foundation: access control gating + admin-action audit log
Why
requireAdmin()(workers/api/src/lib/auth.ts) already checkssession.roles.includes("admin")but is wired to no routes. Before any admin API ships we need: (a) a documented, tested way to grant/verify the admin role, (b) a lightweight probe the admin UI can call, and (c) an audit log so every privileged mutation is recorded (a gap PAS explicitly still has).Scope
GET /v1/admin/me(or/v1/me/is-admin) →{ admin: boolean }, behindrequireUser(notrequireAdmin, so non-admins getfalsenot 403). The UI uses this to decide whether to mount the portal.users.rolesJSON column, read at sign-in (routes/auth.tsupsertUser). Provide a safe way to grant admin — a migration seeding the operator account (serge.the.dev@gmail.com/ operator GitHub id) asadmin, plus a documentedADMIN_ALLOWLISTenv fallback (comma-separated user ids or logins) checked insiderequireAdminso the very first admin isn't a chicken-and-egg problem.admin_audit_logtable (migration):id, actor_user_id, action, target_type, target_id, detail_json, created_at. Add arecordAdminAction(env, session, action, target, detail)helper inlib/./v1/admin/*call is logged.Acceptance criteria
GET /v1/admin/mereturns correct{admin}for admin and non-admin sessions.requireAdminhonors bothusers.rolesand theADMIN_ALLOWLISTenv fallback; unit-tested for allow + 403 deny.admin_audit_logmigration + helper; a mutating admin call writes exactly one row with actor + target + action./v1/admin/*mutating route → 403 and no audit row.Relevant code
workers/api/src/lib/auth.ts(requireAdmin,requireUser)workers/api/src/routes/auth.ts(upsertUser, roles → session)workers/api/migrations/(next number after 0048)References
requireAdminviaADMIN_GITHUB_LOGINS/ADMIN_USER_IDS(fas/platform/packages/backend/src/lib/auth.ts)pas/.../submissions-helpers.ts)Part of the PAGS Admin Portal epic. Blocks all other admin issues.
Best-practice hardening (2026-08-01)
/adminUI and/v1/admin/*API: acloudflareAccessGate()middleware verifies theCf-Access-Jwt-Assertionheader against the team JWKS (RS256), inert untilCF_ACCESS_TEAM_DOMAIN+CF_ACCESS_AUDare set (same pattern as FASverifyAccessJwt). Role check runs behind it. Document turning CF Access on foradmin.*//adminbefore real onboarding.requireAdminresolves admin from: session role → liveusers.rolesDB read →ADMIN_ALLOWLISTenv (break-glass, by uid). So a freshly-granted admin ([Admin][API] Moderation — suspend user, unpublish/delete agent, cancel instance, roles/keys #34) takes effect immediately and a role revocation isn't defeated by an old token.