From 176ab059291d0e4e057dde470687e812ed04c18c Mon Sep 17 00:00:00 2001 From: Serge Ivo Date: Sat, 1 Aug 2026 08:51:33 +1000 Subject: [PATCH] feat(admin): access-control foundation + audit log (#28) First slice of the PAGS Admin Portal epic (#27). Wires up the previously unused requireAdmin and adds the operator plumbing everything else builds on: - requireAdmin hardened to defense-in-depth: admin resolves from the session role -> a live users.roles read (no stale-token lockout / instant grant) -> ADMIN_ALLOWLIST env break-glass (by uid). New isAdmin() helper. - Cloudflare Access perimeter (lib/cf-access.ts) as middleware on /v1/admin/*: verifies Cf-Access-Jwt-Assertion (RS256 vs team JWKS). Inert until CF_ACCESS_TEAM_DOMAIN + CF_ACCESS_AUD are set. - admin_audit_log table (migration 0049) + recordAdminAction()/listAdminAudit() so every future privileged mutation is recorded (closes the audit-trail gap PAS still has). Operator account bootstrapped as admin, idempotently. - New /v1/admin route group: GET /v1/admin/me (is-admin probe for the UI) and GET /v1/admin/audit (filterable log reader). - 7 route tests (role/live-role/allowlist/401/403 + audit); typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/migrations/0049_admin_foundation.sql | 29 +++++ workers/api/src/index.ts | 8 ++ workers/api/src/lib/admin.ts | 74 ++++++++++++ workers/api/src/lib/auth.ts | 31 ++++- workers/api/src/lib/cf-access.ts | 112 ++++++++++++++++++ workers/api/src/routes/admin.test.ts | 100 ++++++++++++++++ workers/api/src/routes/admin.ts | 39 ++++++ workers/api/src/types.ts | 10 ++ 8 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 workers/api/migrations/0049_admin_foundation.sql create mode 100644 workers/api/src/lib/admin.ts create mode 100644 workers/api/src/lib/cf-access.ts create mode 100644 workers/api/src/routes/admin.test.ts create mode 100644 workers/api/src/routes/admin.ts diff --git a/workers/api/migrations/0049_admin_foundation.sql b/workers/api/migrations/0049_admin_foundation.sql new file mode 100644 index 00000000..ae7171f4 --- /dev/null +++ b/workers/api/migrations/0049_admin_foundation.sql @@ -0,0 +1,29 @@ +-- Admin/operator portal foundation (epic: PAGS Admin Portal, issue #28). +-- +-- 1. admin_audit_log: append-only record of every privileged operator action. +-- Handlers call recordAdminAction() (lib/admin.ts) after a successful mutation. +-- This closes the "no admin audit trail" gap PAS still has. +-- 2. Bootstrap the operator account as admin so the very first admin isn't a +-- chicken-and-egg problem. Idempotent: only touches a matching row that isn't +-- already admin. The ADMIN_ALLOWLIST env var is the break-glass fallback if no +-- row matches yet (see requireAdmin in lib/auth.ts). + +CREATE TABLE IF NOT EXISTS admin_audit_log ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + actor_user_id TEXT NOT NULL, -- who did it (session uid) + action TEXT NOT NULL, -- e.g. 'user.suspend', 'agent.unpublish', 'platform_ai.toggle' + target_type TEXT, -- 'user' | 'agent' | 'instance' | 'setting' | ... + target_id TEXT, -- the affected row id + detail TEXT -- JSON: before/after, reason, params +); +CREATE INDEX IF NOT EXISTS idx_admin_audit_time ON admin_audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_admin_audit_actor ON admin_audit_log(actor_user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_admin_audit_target ON admin_audit_log(target_type, target_id, created_at DESC); + +-- Operator bootstrap. Google sign-in stores the email in github_login; the GitHub +-- OAuth login is 'serge-ivo'. Grant admin to whichever row exists. +UPDATE users + SET roles = '["user","admin"]', updated_at = datetime('now') + WHERE github_login IN ('serge.the.dev@gmail.com', 'serge-ivo') + AND (roles IS NULL OR roles NOT LIKE '%admin%'); diff --git a/workers/api/src/index.ts b/workers/api/src/index.ts index c04f3182..36cc1505 100644 --- a/workers/api/src/index.ts +++ b/workers/api/src/index.ts @@ -31,6 +31,8 @@ import { relayRoutes } from "./routes/relay.js"; import { terminalRoutes } from "./routes/terminals.js"; import { usageRoutes } from "./routes/usage.js"; import { triggerRoutes } from "./routes/triggers.js"; +import { adminRoutes } from "./routes/admin.js"; +import { cloudflareAccessGate } from "./lib/cf-access.js"; import { runDueTriggers } from "./lib/triggers.js"; import type { Env } from "./types.js"; @@ -83,6 +85,11 @@ app.use("/v1/push/test", rateLimitStrict()); app.use("/v1/errors/client", rateLimitStrict()); // browser-driven writes to the durable log — throttle hard app.use("/v1/keys/*/reveal", rateLimitStrict()); // hands out a raw decrypted key — throttle hard +// Admin perimeter: Cloudflare Access gate in front of the whole operator API +// (defense-in-depth). Inert until CF_ACCESS_TEAM_DOMAIN + CF_ACCESS_AUD are set; +// the admin ROLE is still enforced per-handler behind this. +app.use("/v1/admin/*", cloudflareAccessGate()); + // ── Routes ───────────────────────────────────────────────────────────────── app.route("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/v1/auth", authRoutes); @@ -115,6 +122,7 @@ app.route("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/v1/triggers", triggerRoutes); // instance webhook + cron triggers app.route("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/v1/errors", errorRoutes); // GET /v1/errors — durable error log read-back app.route("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/v1/public", publicRoutes); // /v1/public/agents/:id, /agents/:id/try, /webhook/:id/ingest app.route("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/v1/billing", billingRoutes); +app.route("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/v1/admin", adminRoutes); // operator portal: /me, /audit (+ users, agents, usage, moderation) app.get("/health", (c) => c.json({ ok: true, service: "proagentstore-api" })); diff --git a/workers/api/src/lib/admin.ts b/workers/api/src/lib/admin.ts new file mode 100644 index 00000000..4bd08ffe --- /dev/null +++ b/workers/api/src/lib/admin.ts @@ -0,0 +1,74 @@ +import type { Env, SessionPayload } from "../types.js"; + +/** + * Admin-action audit log (issue #28). Every privileged mutation in a /v1/admin/* + * handler MUST call this after it succeeds, so there's a durable record of who did + * what to whom. Best-effort: a logging failure never breaks the action (mirrors + * lib/error-log.ts / recordUsage). Read back via GET /v1/admin/audit. + */ +export async function recordAdminAction( + env: Env, + actor: SessionPayload, + action: string, + target?: { type?: string; id?: string }, + detail?: unknown, +): Promise { + try { + await env.DB.prepare( + `INSERT INTO admin_audit_log (id, actor_user_id, action, target_type, target_id, detail) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, + ) + .bind( + crypto.randomUUID(), + actor.uid, + action, + target?.type ?? null, + target?.id ?? null, + detail === undefined ? null : JSON.stringify(detail), + ) + .run(); + } catch { + // swallow — auditing must never break the underlying admin action + } +} + +export interface AdminAuditRow { + id: string; + created_at: string; + actor_user_id: string; + action: string; + target_type: string | null; + target_id: string | null; + detail: string | null; +} + +/** Read back the admin audit log (newest first), with optional filters. */ +export async function listAdminAudit( + env: Env, + opts: { actor?: string; action?: string; targetId?: string; limit?: number } = {}, +): Promise { + const where: string[] = []; + const binds: unknown[] = []; + if (opts.actor) { + binds.push(opts.actor); + where.push(`actor_user_id = ?${binds.length}`); + } + if (opts.action) { + binds.push(opts.action); + where.push(`action = ?${binds.length}`); + } + if (opts.targetId) { + binds.push(opts.targetId); + where.push(`target_id = ?${binds.length}`); + } + const limit = Math.min(Math.max(opts.limit ?? 100, 1), 500); + const sql = `SELECT id, created_at, actor_user_id, action, target_type, target_id, detail + FROM admin_audit_log + ${where.length ? `WHERE ${where.join(" AND ")}` : ""} + ORDER BY created_at DESC + LIMIT ${limit}`; + const res = await env.DB.prepare(sql) + .bind(...binds) + .all(); + return res.results ?? []; +} diff --git a/workers/api/src/lib/auth.ts b/workers/api/src/lib/auth.ts index 588060fd..9de955e4 100644 --- a/workers/api/src/lib/auth.ts +++ b/workers/api/src/lib/auth.ts @@ -30,12 +30,39 @@ export async function requireUser( return session; } -/** Require 'admin' role. */ +/** + * Resolve whether a session is an admin (issue #28), defense-in-depth: + * 1. the role baked into the session token (fast path), then + * 2. a LIVE `users.roles` read — so a freshly granted/revoked admin takes effect + * immediately without waiting for the 30-day token to expire, then + * 3. the ADMIN_ALLOWLIST env (break-glass, by uid) — bootstraps the first admin. + */ +export async function isAdmin( + c: Context<{ Bindings: Env }>, + session: SessionPayload, +): Promise { + if (session.roles.includes("admin")) return true; + try { + const row = await c.env.DB.prepare("SELECT roles FROM users WHERE id = ?1") + .bind(session.uid) + .first<{ roles: string }>(); + if (row?.roles && (JSON.parse(row.roles) as string[]).includes("admin")) return true; + } catch { + // fall through to allowlist + } + const allow = (c.env.ADMIN_ALLOWLIST || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return allow.includes(session.uid); +} + +/** Require 'admin' role (see isAdmin for the resolution order). */ export async function requireAdmin( c: Context<{ Bindings: Env }>, ): Promise { const session = await requireUser(c); - if (!session.roles.includes("admin")) { + if (!(await isAdmin(c, session))) { throw new HttpError(403, "Admin access required"); } return session; diff --git a/workers/api/src/lib/cf-access.ts b/workers/api/src/lib/cf-access.ts new file mode 100644 index 00000000..a3fcb058 --- /dev/null +++ b/workers/api/src/lib/cf-access.ts @@ -0,0 +1,112 @@ +import type { Context, Next } from "hono"; +import type { Env } from "../types.js"; +import { HttpError } from "./auth.js"; + +/** + * Cloudflare Access perimeter for the admin surface (defense-in-depth, issue #28). + * + * When `CF_ACCESS_TEAM_DOMAIN` + `CF_ACCESS_AUD` are configured, every request must + * carry a valid `Cf-Access-Jwt-Assertion` header (injected by Cloudflare's edge for + * users who passed the Access policy — SSO + optional hardware key). We verify it + * (RS256 against the team JWKS) so a leaked account JWT alone can't reach the admin + * API. The role check (requireAdmin) still runs behind this. + * + * INERT until configured: if either env var is unset (local/dev, and current prod + * until CF Access is turned on for admin.*), the gate is a no-op. This mirrors FAS's + * verifyAccessJwt so the perimeter can be switched on with zero code changes. + */ + +interface Jwk { + kid: string; + kty: string; + alg?: string; + n: string; + e: string; +} + +// In-memory JWKS cache (per isolate). The team certs rotate rarely; cache for an +// hour so we don't fetch on every admin request. +let jwksCache: { domain: string; keys: Jwk[]; fetchedAt: number } | null = null; +const JWKS_TTL_MS = 60 * 60 * 1000; + +async function getJwks(teamDomain: string): Promise { + const now = Date.now(); + if (jwksCache && jwksCache.domain === teamDomain && now - jwksCache.fetchedAt < JWKS_TTL_MS) { + return jwksCache.keys; + } + const url = `https://${teamDomain}/cdn-cgi/access/certs`; + const res = await fetch(url); + if (!res.ok) throw new HttpError(503, "Could not fetch Cloudflare Access certs"); + const data = (await res.json()) as { keys?: Jwk[] }; + const keys = data.keys || []; + jwksCache = { domain: teamDomain, keys, fetchedAt: now }; + return keys; +} + +function b64urlToUint8(b64url: string): Uint8Array { + const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(b64url.length / 4) * 4, "="); + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +async function verifyAccessJwt(token: string, teamDomain: string, aud: string): Promise { + const parts = token.split("."); + if (parts.length !== 3) return false; + const [headerB64, payloadB64, sigB64] = parts; + + let header: { kid?: string; alg?: string }; + let payload: { aud?: string | string[]; exp?: number; iss?: string }; + try { + header = JSON.parse(new TextDecoder().decode(b64urlToUint8(headerB64))); + payload = JSON.parse(new TextDecoder().decode(b64urlToUint8(payloadB64))); + } catch { + return false; + } + if (header.alg !== "RS256" || !header.kid) return false; + + // Claims: audience must include our AUD; must not be expired; issuer is the team. + const auds = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : []; + if (!auds.includes(aud)) return false; + if (payload.exp && payload.exp * 1000 < Date.now()) return false; + if (payload.iss && payload.iss !== `https://${teamDomain}`) return false; + + const keys = await getJwks(teamDomain); + const jwk = keys.find((k) => k.kid === header.kid); + if (!jwk) return false; + + const key = await crypto.subtle.importKey( + "jwk", + { kty: jwk.kty, n: jwk.n, e: jwk.e, alg: "RS256", ext: true }, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + return crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, b64urlToUint8(sigB64), data); +} + +/** Is the CF Access perimeter configured for this environment? */ +export function cloudflareAccessConfigured(env: Env): boolean { + return Boolean(env.CF_ACCESS_TEAM_DOMAIN && env.CF_ACCESS_AUD); +} + +/** + * Hono middleware: enforce Cloudflare Access on the admin surface. No-op unless + * configured. Throws HttpError(403) when a required Access token is missing/invalid. + */ +export function cloudflareAccessGate() { + return async (c: Context<{ Bindings: Env }>, next: Next) => { + if (!cloudflareAccessConfigured(c.env)) return next(); + const token = c.req.header("Cf-Access-Jwt-Assertion"); + if (!token) throw new HttpError(403, "Cloudflare Access required"); + const ok = await verifyAccessJwt( + token, + c.env.CF_ACCESS_TEAM_DOMAIN as string, + c.env.CF_ACCESS_AUD as string, + ).catch(() => false); + if (!ok) throw new HttpError(403, "Invalid Cloudflare Access token"); + return next(); + }; +} diff --git a/workers/api/src/routes/admin.test.ts b/workers/api/src/routes/admin.test.ts new file mode 100644 index 00000000..91ac54c3 --- /dev/null +++ b/workers/api/src/routes/admin.test.ts @@ -0,0 +1,100 @@ +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; +import { HttpError } from "../lib/auth.js"; +import { signSession } from "../lib/session.js"; +import { adminRoutes } from "./admin.js"; + +const TEST_SECRET = "test-secret"; + +/** + * Build a test app with a mocked D1. `dbRoles` is what `SELECT roles FROM users` + * returns for the live-role check; `allowlist` seeds ADMIN_ALLOWLIST; `audit` is + * the rows the audit query returns. + */ +function testApp(opts: { dbRoles?: string | null; allowlist?: string; audit?: unknown[] } = {}) { + const app = new Hono(); + app.route("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/v1/admin", adminRoutes); + app.onError((err, c) => { + if (err instanceof HttpError) return c.json({ error: err.message }, err.status as 400); + throw err; + }); + const env = { + SESSION_SIGNING_KEY: TEST_SECRET, + ADMIN_ALLOWLIST: opts.allowlist, + DB: { + prepare(sql: string) { + return { + bind() { + return { + first: async () => + sql.includes("FROM users") ? { roles: opts.dbRoles ?? null } : null, + all: async () => ({ results: opts.audit ?? [] }), + run: async () => ({}), + }; + }, + }; + }, + }, + }; + return { app, env }; +} + +async function token(uid: string, roles: string[]) { + return signSession(uid, TEST_SECRET, { roles }); +} + +function req(app: Hono, env: unknown, path: string, tok?: string) { + return app.request(path, { headers: tok ? { Authorization: `Bearer ${tok}` } : {} }, env); +} + +describe("GET /v1/admin/me", () => { + it("returns admin:true when the session carries the admin role", async () => { + const { app, env } = testApp(); + const res = await req(app, env, "/v1/admin/me", await token("u1", ["user", "admin"])); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ admin: true }); + }); + + it("returns admin:false (not 403) for a non-admin", async () => { + const { app, env } = testApp({ dbRoles: '["user"]' }); + const res = await req(app, env, "/v1/admin/me", await token("u2", ["user"])); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ admin: false }); + }); + + it("honors a live users.roles grant even when the token lacks admin", async () => { + const { app, env } = testApp({ dbRoles: '["user","admin"]' }); + const res = await req(app, env, "/v1/admin/me", await token("u3", ["user"])); + expect(await res.json()).toEqual({ admin: true }); + }); + + it("honors the ADMIN_ALLOWLIST break-glass fallback by uid", async () => { + const { app, env } = testApp({ dbRoles: '["user"]', allowlist: "u4, u9" }); + const res = await req(app, env, "/v1/admin/me", await token("u4", ["user"])); + expect(await res.json()).toEqual({ admin: true }); + }); + + it("401s without a token", async () => { + const { app, env } = testApp(); + const res = await req(app, env, "/v1/admin/me"); + expect(res.status).toBe(401); + }); +}); + +describe("GET /v1/admin/audit", () => { + it("403s a non-admin", async () => { + const { app, env } = testApp({ dbRoles: '["user"]' }); + const res = await req(app, env, "/v1/admin/audit", await token("u2", ["user"])); + expect(res.status).toBe(403); + }); + + it("returns rows for an admin", async () => { + const audit = [ + { id: "a1", created_at: "2026-08-01T00:00:00Z", actor_user_id: "u1", action: "user.suspend", target_type: "user", target_id: "u9", detail: null }, + ]; + const { app, env } = testApp({ audit }); + const res = await req(app, env, "/v1/admin/audit", await token("u1", ["admin"])); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ count: 1, audit }); + }); +}); diff --git a/workers/api/src/routes/admin.ts b/workers/api/src/routes/admin.ts new file mode 100644 index 00000000..600d21ea --- /dev/null +++ b/workers/api/src/routes/admin.ts @@ -0,0 +1,39 @@ +import { Hono } from "hono"; +import { isAdmin, requireAdmin, requireUser } from "../lib/auth.js"; +import { listAdminAudit } from "../lib/admin.js"; +import type { Env } from "../types.js"; + +/** + * Admin/operator portal API (epic: PAGS Admin Portal). Every route here is behind + * the admin gate. The network perimeter (Cloudflare Access) is applied as + * middleware on /v1/admin/* in index.ts (defense-in-depth); these handlers enforce + * the admin ROLE. This file is the foundation (issue #28): the is-admin probe and + * the audit-log reader. Feature endpoints (users, agents, usage, moderation) mount + * here in later issues. + */ +export const adminRoutes = new Hono<{ Bindings: Env }>(); + +/** + * GET /v1/admin/me — lightweight probe the admin UI calls on load to decide whether + * to mount the portal. Behind requireUser (NOT requireAdmin) so a non-admin gets + * `{ admin: false }` instead of a 403 the UI would have to special-case. + */ +adminRoutes.get("/me", async (c) => { + const session = await requireUser(c); + return c.json({ admin: await isAdmin(c, session) }); +}); + +/** + * GET /v1/admin/audit — read back the admin-action audit log (newest first). + * Filters: ?actor= ?action= ?target= ?limit= + */ +adminRoutes.get("/audit", async (c) => { + await requireAdmin(c); + const rows = await listAdminAudit(c.env, { + actor: c.req.query("actor") || undefined, + action: c.req.query("action") || undefined, + targetId: c.req.query("target") || undefined, + limit: Number(c.req.query("limit")) || undefined, + }); + return c.json({ count: rows.length, audit: rows }); +}); diff --git a/workers/api/src/types.ts b/workers/api/src/types.ts index d73d603f..8cfffd46 100644 --- a/workers/api/src/types.ts +++ b/workers/api/src/types.ts @@ -48,6 +48,16 @@ export interface Env { VAPID_PUBLIC_KEY?: string; VAPID_PRIVATE_KEY?: string; VAPID_SUBJECT?: string; + /** + * Admin/operator portal (issue #28). + * ADMIN_ALLOWLIST: comma-separated session uids granted admin as a break-glass + * fallback, checked in requireAdmin in addition to users.roles. + * CF_ACCESS_*: when both set, the /v1/admin/* API + /admin UI require a valid + * Cloudflare Access token (defense-in-depth). Inert until configured. + */ + ADMIN_ALLOWLIST?: string; + CF_ACCESS_TEAM_DOMAIN?: string; + CF_ACCESS_AUD?: string; } export interface SessionPayload {