Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions workers/api/migrations/0049_admin_foundation.sql
Original file line number Diff line number Diff line change
@@ -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%');
8 changes: 8 additions & 0 deletions workers/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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("/v1/auth", authRoutes);
Expand Down Expand Up @@ -115,6 +122,7 @@ app.route("/v1/triggers", triggerRoutes); // instance webhook + cron triggers
app.route("/v1/errors", errorRoutes); // GET /v1/errors — durable error log read-back
app.route("/v1/public", publicRoutes); // /v1/public/agents/:id, /agents/:id/try, /webhook/:id/ingest
app.route("/v1/billing", billingRoutes);
app.route("/v1/admin", adminRoutes); // operator portal: /me, /audit (+ users, agents, usage, moderation)

app.get("/health", (c) => c.json({ ok: true, service: "proagentstore-api" }));

Expand Down
74 changes: 74 additions & 0 deletions workers/api/src/lib/admin.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<AdminAuditRow[]> {
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<AdminAuditRow>();
return res.results ?? [];
}
31 changes: 29 additions & 2 deletions workers/api/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<SessionPayload> {
const session = await requireUser(c);
if (!session.roles.includes("admin")) {
if (!(await isAdmin(c, session))) {
throw new HttpError(403, "Admin access required");
}
return session;
Expand Down
112 changes: 112 additions & 0 deletions workers/api/src/lib/cf-access.ts
Original file line number Diff line number Diff line change
@@ -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<Jwk[]> {
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<boolean> {
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();
};
}
100 changes: 100 additions & 0 deletions workers/api/src/routes/admin.test.ts
Original file line number Diff line number Diff line change
@@ -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("/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 });
});
});
Loading
Loading