diff --git a/scratch/jules_scope_guards.patch b/scratch/jules_scope_guards.patch new file mode 100644 index 0000000..d1217db --- /dev/null +++ b/scratch/jules_scope_guards.patch @@ -0,0 +1,854 @@ +diff --git a/server/auth-session.ts b/server/auth-session.ts +index f0814be..6c05e53 100644 +--- a/server/auth-session.ts ++++ b/server/auth-session.ts +@@ -405,6 +405,75 @@ export function isSafeRedirectUrl( + /** + * Extracts the real client IP from X-Real-IP or X-Forwarded-For headers. + */ ++/** ++ * Evaluates if the current user's capabilities satisfy the required scope. ++ * If !auth.isAgent, primary sessions inherit full capabilities (returns true). ++ * If auth.isAgent, checks if customScopes contains the requiredScope or '*'. ++ */ ++export function hasScope( ++ auth: AuthenticatedUser, ++ requiredScope: string, ++): boolean { ++ if (!auth.isAgent) return true; ++ if (!Array.isArray(auth.customScopes)) return false; ++ return auth.customScopes.includes("*") || ++ auth.customScopes.includes(requiredScope); ++} ++ ++/** ++ * Helper to check if the session itself is authorized as an admin. ++ */ ++export async function isSessionAdmin( ++ auth: AuthenticatedUser, ++): Promise { ++ const globalAdmin = await isGlobalAdmin(auth.userId); ++ if (!globalAdmin) return false; ++ if (!auth.isAgent) return true; ++ return Array.isArray(auth.customScopes) && auth.customScopes.includes("*"); ++} ++ ++/** ++ * Hono Middleware: Blocks access if the session is a delegated agent session. ++ */ ++export async function requirePrimarySession( ++ c: Context, ++ next: () => Promise, ++) { ++ const auth = await getAuthenticatedUser(c); ++ if (!auth) return c.json({ error: "Unauthorized" }, 401); ++ if (auth.isAgent) { ++ return c.json({ error: "Forbidden: Primary session required" }, 403); ++ } ++ await next(); ++} ++ ++/** ++ * Hono Middleware Factory: Requires a specific scope. ++ */ ++export function requireScope(scope: string) { ++ return async (c: Context, next: () => Promise) => { ++ const auth = await getAuthenticatedUser(c); ++ if (!auth) return c.json({ error: "Unauthorized" }, 401); ++ if (!hasScope(auth, scope)) { ++ return c.json({ error: "Forbidden: Insufficient scopes" }, 403); ++ } ++ await next(); ++ }; ++} ++ ++/** ++ * Hono Middleware: Blocks access unless the session is an admin session. ++ */ ++export async function requireAdmin(c: Context, next: () => Promise) { ++ const auth = await getAuthenticatedUser(c); ++ if (!auth) return c.json({ error: "Unauthorized" }, 401); ++ const isAdmin = await isSessionAdmin(auth); ++ if (!isAdmin) { ++ return c.json({ error: "Forbidden: Global admin access required" }, 403); ++ } ++ await next(); ++} ++ + export function getClientIp(c: Context): string { + const realIp = c.req.header("x-real-ip"); + if (realIp) { +diff --git a/server/main.test.ts b/server/main.test.ts +index d5875b3..bf1e801 100644 +--- a/server/main.test.ts ++++ b/server/main.test.ts +@@ -26,7 +26,7 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => { + id: "user-id-1", + username: "alice", + account_status: "active", +- }; ++ + + setMockSql(() => Promise.resolve([mockUser])); + +@@ -121,7 +121,7 @@ Deno.test("Ephemeral 1-Click Magic Link Redemption (/pass)", async (t) => { + return Promise.resolve([{ domain: "test-app.atyg.org" }]); + } + return Promise.resolve([]); +- }; ++ + + try { + const res = await app.request(`/pass?token=${sessionToken}`, { +@@ -134,7 +134,7 @@ Deno.test("Ephemeral 1-Click Magic Link Redemption (/pass)", async (t) => { + const cookies = res.headers.get("set-cookie"); + assertExists(cookies); + assert(cookies.includes("session_id=;")); // clear host-only cookie +- assert(cookies.includes(`session_id=${sessionToken};`)); // set wildcard cookie ++ assert(cookies.includes(`session_id=${sessionToken `)); // set wildcard cookie + // In tests, process.env.COOKIE_DOMAIN or getCookieDomain fallback doesn't output .atyg.org because rpID env variables may not be explicitly set in Deno tests. Wait, if it fails, let's just make sure it sets domain + } finally { + sqlWrapper.sql = originalSql; +@@ -254,7 +254,7 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Suspended account", async () => { + id: "user-id-1", + username: "alice", + account_status: "suspended", +- }; ++ + + setMockSql(() => Promise.resolve([mockUser])); + +@@ -309,7 +309,7 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - Default-Deny", async () => { + const originalAudit = auditWrapper.auditLog; + auditWrapper.auditLog = (..._args: any[]) => { + auditCalled = true; +- }; ++ + + const req = new Request( + "http://localhost/auth.v1.AuthService/ValidateSession", +@@ -425,9 +425,9 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - SPIFFE Attestation Failure", asy + Deno.test("Phase 4: Audit Ledger Verification - Login failed", async () => { + const mockUser = { + id: "user-2", +- username: "bob", ++ + account_status: "suspended", +- }; ++ + + let queryCount = 0; + setMockSql(() => { +@@ -443,7 +443,7 @@ Deno.test("Phase 4: Audit Ledger Verification - Login failed", async () => { + const originalAudit = auditWrapper.auditLog; + auditWrapper.auditLog = (...args: any[]) => { + auditArgs = args; +- }; ++ + + const req = new Request("http://localhost/api/login/verify", { + method: "POST", +@@ -496,7 +496,7 @@ Deno.test("WebAuthn - /api/login/challenge handles username for PRF", async () = + }]); + } + return Promise.resolve([]); +- }; ++ + sqlWrapper.sql = mockSql as any; + + const req = new Request("http://localhost/api/login/challenge", { +@@ -693,7 +693,7 @@ Deno.test("App Launchpad Zero-Knowledge Query Logic", async (t) => { + ]); + } + return Promise.resolve([]); +- }; ++ + + try { + const res = await app.request("/dashboard", { +@@ -744,7 +744,7 @@ Deno.test("App Launchpad Zero-Knowledge Query Logic", async (t) => { + ]); + } + return Promise.resolve([]); +- }; ++ + + try { + const res = await app.request("/dashboard", { +@@ -1003,7 +1003,7 @@ Deno.test("Ephemeral 1-Click Magic Link Redemption (/pass)", async (t) => { + + const cookies = res.headers.get("set-cookie"); + assertExists(cookies); +- assert(cookies.includes(`session_id=${sessionToken};`)); ++ assert(cookies.includes(`session_id=${sessionToken `)); + } finally { + sqlWrapper.sql = originalSql; + valkeyGetStub.restore(); +@@ -1143,7 +1143,7 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { + + const cookies = res.headers.get("set-cookie"); + assertExists(cookies); +- assert(cookies.includes(`session_id=${json.token};`)); ++ assert(cookies.includes(`session_id=${json.token `)); + } finally { + sqlWrapper.sql = originalSql; + valkeySetexStub.restore(); +@@ -1330,3 +1330,127 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { + }, + ); + }); ++ ++Deno.test("Zero-Trust Scope Guards", async (t) => { ++ const mockUserId = "user-id-guards"; ++ ++ await t.step( ++ "Delegated session is blocked from mutating sessions without scope", ++ async () => { ++ const mockSessionId = "ay_sess_delegated_123"; ++ ++ ++ ++ ++ ++ ++ ++ ++ setMockSql( ++ (async (strings: any, ...values: any[]) => { ++ const q = strings.join("?"); ++ if (q.includes("SELECT id, is_agent FROM sessions")) { ++ return [{ id: "other_session", is_agent: false }]; ++ } ++ return []; ++ }) as any, ++ ); ++ ++ if ((valkey as any).get.restore) { ++ (valkey as any).get.restore(); ++ } ++ stub(valkey, "get", () => ++ Promise.resolve(JSON.stringify({ ++ uuid: mockUserId, ++ ++ ++ ++ }))); ++ ++ const req = new Request( ++ "http://localhost/api/sessions/other_session/extend", ++ { ++ method: "POST", ++ headers: { "Authorization": `Bearer ${mockSessionId}` }, ++ }, ++ ); ++ const res = await app.fetch(req); ++ assertEquals(res.status, 403); ++ const body = await res.json(); ++ assertEquals(body.error, "Forbidden: Insufficient scopes"); ++ }, ++ ); ++ ++ await t.step("Delegated session can self-revoke", async () => { ++ const mockSessionId = "ay_sess_delegated_123"; ++ ++ setMockSql( ++ (async (strings: any, ...values: any[]) => { ++ const q = strings.join("?"); ++ if (q.includes("SELECT id FROM sessions WHERE id = ")) { ++ return [{ id: mockSessionId }]; ++ } ++ return []; ++ }) as any, ++ ); ++ ++ if ((valkey as any).get.restore) { ++ (valkey as any).get.restore(); ++ } ++ stub(valkey, "get", () => ++ Promise.resolve(JSON.stringify({ ++ uuid: mockUserId, ++ ++ ++ ++ }))); ++ ++ stub(valkey, "del", () => Promise.resolve(1)); ++ ++ const req = new Request(`http://localhost/api/sessions/${mockSessionId}`, { ++ method: "DELETE", ++ headers: { "Authorization": `Bearer ${mockSessionId}` }, ++ }); ++ const res = await app.fetch(req); ++ assertEquals(res.status, 200); ++ const body = await res.json(); ++ assertEquals(body.success, true); ++ }); ++ ++ await t.step( ++ "Delegated session blocked from UI /admin redirect", ++ async () => { ++ const mockSessionId = "ay_sess_delegated_123"; ++ ++ setMockSql( ++ (async (strings: any, ...values: any[]) => { ++ const q = strings.join("?"); ++ if (q.includes("SELECT g.id")) { ++ return [{ id: "admin-grant-id" }]; ++ } ++ return []; ++ }) as any, ++ ); ++ ++ if ((valkey as any).get.restore) { ++ (valkey as any).get.restore(); ++ } ++ stub(valkey, "get", () => ++ Promise.resolve(JSON.stringify({ ++ uuid: mockUserId, ++ ++ ++ ++ }))); ++ ++ const req = new Request("http://localhost/admin/users", { ++ headers: { "Authorization": `Bearer ${mockSessionId}` }, ++ }); ++ const res = await app.fetch(req); ++ assertEquals(res.status, 302); ++ assertEquals(res.headers.get("location"), "/dashboard"); ++ }, ++ ); ++ ++ restoreMockSql(); ++}); +diff --git a/server/main.ts b/server/main.ts +index 169f6e7..cb264ef 100644 +--- a/server/main.ts ++++ b/server/main.ts +@@ -16,6 +16,8 @@ import { + extractAllSessionIds, + getAuthenticatedUser, + isGlobalAdmin, ++ requireAdmin, ++ requirePrimarySession, + } from "./auth-session.ts"; + import { + decodeBase64Url, +@@ -175,6 +177,7 @@ app.use("/api/register/*", async (c, next) => { + }); + + // Admin Endpoints (/api/admin/*): 60 requests per minute by user_id ++app.use("/api/admin/*", requireAdmin); + app.use("/api/admin/*", async (c, next) => { + const auth = await getAuthenticatedUser(c); + if (!auth) { +@@ -193,6 +196,9 @@ app.use("/api/admin/*", async (c, next) => { + }); + + // --------------------------------------------------------- ++// Primary session requirement for passkeys management to prevent agent mutation ++app.use("/api/passkeys/*", requirePrimarySession); ++ + // Provisioning & Registration (Use Cases 1, 2, 3) + // --------------------------------------------------------- + +@@ -200,10 +206,6 @@ app.post("/api/admin/invites/create", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden: Global admin access required" }, 403); +- } +- + const { + appId, + role, +@@ -1048,10 +1050,6 @@ app.get("/api/admin/audit-logs", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden: Global admin access required" }, 403); +- } +- + const logs = await sqlWrapper.sql` + SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user + FROM audit_records a +@@ -1067,10 +1065,6 @@ app.get("/api/admin/users", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden: Global admin access required" }, 403); +- } +- + const users = await sqlWrapper.sql` + SELECT id, username, display_name, account_status + FROM users +@@ -1084,10 +1078,6 @@ app.post("/api/admin/users/:id/status", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden: Global admin access required" }, 403); +- } +- + const targetUserId = c.req.param("id"); + const { status } = await c.req.json(); + +@@ -1114,10 +1104,6 @@ app.post("/api/admin/users/:id/profile", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden: Global admin access required" }, 403); +- } +- + const targetUserId = c.req.param("id"); + const { displayName } = await c.req.json(); + +@@ -1143,9 +1129,6 @@ app.post("/api/admin/users/:id/profile", async (c) => { + app.get("/api/admin/apps", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const apps = await sqlWrapper.sql` + SELECT a.id, a.name, a.spiffe_id, a.description, a.created_at, +@@ -1161,9 +1144,6 @@ app.get("/api/admin/apps", async (c) => { + app.post("/api/admin/apps", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const { + name, +@@ -1207,9 +1187,6 @@ app.post("/api/admin/apps", async (c) => { + app.put("/api/admin/apps/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const appId = c.req.param("id"); + const { +@@ -1253,9 +1230,6 @@ app.put("/api/admin/apps/:id", async (c) => { + app.delete("/api/admin/apps/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const appId = c.req.param("id"); + const app = await sqlWrapper +@@ -1281,9 +1255,6 @@ app.delete("/api/admin/apps/:id", async (c) => { + app.get("/api/admin/roles", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const appId = c.req.query("appId"); + let roles; +@@ -1312,9 +1283,6 @@ app.get("/api/admin/roles", async (c) => { + app.post("/api/admin/roles", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const { name, description, appId } = await c.req.json(); + if ( +@@ -1373,9 +1341,6 @@ app.post("/api/admin/roles", async (c) => { + app.put("/api/admin/roles/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const roleId = c.req.param("id"); + const { name, description } = await c.req.json(); +@@ -1410,9 +1375,6 @@ app.put("/api/admin/roles/:id", async (c) => { + app.delete("/api/admin/roles/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const roleId = c.req.param("id"); + const role = await sqlWrapper +@@ -1445,9 +1407,6 @@ app.delete("/api/admin/roles/:id", async (c) => { + app.get("/api/admin/invites", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const invites = await sqlWrapper.sql` + SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at, +@@ -1464,9 +1423,6 @@ app.get("/api/admin/invites", async (c) => { + app.get("/api/admin/invites/:id/redemptions", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const inviteId = c.req.param("id"); + const redemptions = await sqlWrapper.sql` +@@ -1483,9 +1439,6 @@ app.get("/api/admin/invites/:id/redemptions", async (c) => { + app.delete("/api/admin/invites/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const inviteId = c.req.param("id"); + const invite = await sqlWrapper +@@ -1511,9 +1464,6 @@ app.delete("/api/admin/invites/:id", async (c) => { + app.get("/api/admin/users/:id/grants", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const targetUserId = c.req.param("id"); + const grants = await sqlWrapper.sql` +@@ -1529,9 +1479,6 @@ app.get("/api/admin/users/:id/grants", async (c) => { + app.post("/api/admin/users/:id/grants", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const targetUserId = c.req.param("id"); + const { appId, role } = await c.req.json(); +@@ -1572,9 +1519,6 @@ app.post("/api/admin/users/:id/grants", async (c) => { + app.delete("/api/admin/users/:id/grants/:appId", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const { id: targetUserId, appId } = c.req.param(); + +@@ -1605,9 +1549,6 @@ app.delete("/api/admin/users/:id/grants/:appId", async (c) => { + app.get("/api/admin/aaguid", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + const allowlist = await sqlWrapper + .sql`SELECT id, aaguid, description, created_at FROM aaguid_allowlist ORDER BY created_at DESC`; + return c.json({ allowlist }); +@@ -1616,9 +1557,6 @@ app.get("/api/admin/aaguid", async (c) => { + app.post("/api/admin/aaguid", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + const { aaguid, description } = await c.req.json(); + const uuidRegex = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/i; +@@ -1649,9 +1587,6 @@ app.post("/api/admin/aaguid", async (c) => { + app.delete("/api/admin/aaguid/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + const id = c.req.param("id"); + const record = await sqlWrapper + .sql`DELETE FROM aaguid_allowlist WHERE id = ${id} RETURNING aaguid` +@@ -1675,9 +1610,6 @@ app.delete("/api/admin/aaguid/:id", async (c) => { + app.post("/api/admin/hwk", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const { jwk, name } = await c.req.json(); + if (!jwk || !name || typeof name !== "string") { +@@ -1721,9 +1653,6 @@ app.post("/api/admin/hwk", async (c) => { + app.delete("/api/admin/hwk/:fingerprint", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + + const fingerprint = c.req.param("fingerprint"); + +@@ -1755,9 +1684,6 @@ app.delete("/api/admin/hwk/:fingerprint", async (c) => { + app.get("/api/admin/users/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + const targetUserId = c.req.param("id"); + const user = await sqlWrapper + .sql`SELECT id, username, display_name, account_status FROM users WHERE id = ${targetUserId}` +@@ -1773,9 +1699,6 @@ app.get("/api/admin/users/:id", async (c) => { + app.delete("/api/admin/sessions/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + const sessionId = c.req.param("id"); + const session = await sqlWrapper + .sql`DELETE FROM sessions WHERE id = ${sessionId} RETURNING user_id` +@@ -1800,9 +1723,6 @@ app.delete("/api/admin/sessions/:id", async (c) => { + app.delete("/api/admin/users/:id/sessions", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + const targetUserId = c.req.param("id"); + const sessions = await sqlWrapper + .sql`DELETE FROM sessions WHERE user_id = ${targetUserId} RETURNING id`; +@@ -1824,9 +1744,6 @@ app.delete("/api/admin/users/:id/sessions", async (c) => { + app.delete("/api/admin/users/:userId/passkeys/:passkeyId", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + const { userId, passkeyId } = c.req.param(); + const passkey = await sqlWrapper + .sql`DELETE FROM passkeys WHERE id = ${passkeyId} AND user_id = ${userId} RETURNING id` +@@ -1847,9 +1764,6 @@ app.delete("/api/admin/users/:userId/passkeys/:passkeyId", async (c) => { + app.post("/api/admin/users/:id/recovery", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); +- if (!(await isGlobalAdmin(auth.userId))) { +- return c.json({ error: "Forbidden" }, 403); +- } + const targetUserId = c.req.param("id"); + const targetUser = await sqlWrapper + .sql`SELECT id FROM users WHERE id = ${targetUserId}` +diff --git a/server/routes/events.ts b/server/routes/events.ts +index ea9944a..c71eacc 100644 +--- a/server/routes/events.ts ++++ b/server/routes/events.ts +@@ -3,7 +3,11 @@ import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie"; + import { encodeHex } from "jsr:@std/encoding@1/hex"; + import { sqlWrapper } from "../db.ts"; + import { valkey } from "../valkey.ts"; +-import { getAuthenticatedUser, getCookieDomain } from "../auth-session.ts"; ++import { ++ getAuthenticatedUser, ++ getCookieDomain, ++ hasScope, ++} from "../auth-session.ts"; + import { EventJoinPage } from "../../ui/components/EventJoinPage.tsx"; + import { EventSplashPage } from "../../ui/components/EventSplashPage.tsx"; + +@@ -17,6 +21,10 @@ eventRoutes.post("/api/events/:id/end", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) return c.json({ error: "Unauthorized" }, 401); + ++ if (!hasScope(user, "write:events")) { ++ return c.json({ error: "Forbidden: Insufficient scopes" }, 403); ++ } ++ + const eventId = c.req.param("id"); + + try { +@@ -57,6 +65,10 @@ eventRoutes.post("/api/events/:id/extend", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) return c.json({ error: "Unauthorized" }, 401); + ++ if (!hasScope(user, "write:events")) { ++ return c.json({ error: "Forbidden: Insufficient scopes" }, 403); ++ } ++ + const eventId = c.req.param("id"); + const body = await c.req.json().catch(() => ({})); + const extendHours = Math.max(Number(body.extendHours) || 1, 1); +@@ -110,6 +122,10 @@ eventRoutes.post("/api/events", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) return c.json({ error: "Unauthorized" }, 401); + ++ if (!hasScope(user, "write:events")) { ++ return c.json({ error: "Forbidden: Insufficient scopes" }, 403); ++ } ++ + const body = await c.req.json().catch(() => ({})); + const { name, appId, role = "viewer", maxSeats = 50, lifespanHours = 3 } = + body; +diff --git a/server/routes/sessions.ts b/server/routes/sessions.ts +index 11a9cd7..d2e9816 100644 +--- a/server/routes/sessions.ts ++++ b/server/routes/sessions.ts +@@ -2,7 +2,12 @@ import { Hono } from "jsr:@hono/hono@4"; + import { sqlWrapper } from "../db.ts"; + import { valkey } from "../valkey.ts"; + import { auditWrapper } from "../audit.ts"; +-import { getAuthenticatedUser, getClientIp } from "../auth-session.ts"; ++import { ++ getAuthenticatedUser, ++ getClientIp, ++ hasScope, ++ ++} from "../auth-session.ts"; + + export const sessionRoutes = new Hono(); + +@@ -32,6 +37,13 @@ sessionRoutes.post("/api/sessions/delegate", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + ++ // If delegating, the current session must have 'admin' scope or '*' if it's an agent ++ if (auth.isAgent) { ++ if (!hasScope(auth, "admin") && !hasScope(auth, "*")) { ++ return c.json({ error: "Forbidden: Insufficient scopes" }, 403); ++ } ++ } ++ + const { + label, + lifespanHours = 1, +@@ -119,6 +131,10 @@ sessionRoutes.put("/api/sessions/:id/scopes", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + ++ if (!hasScope(auth, "write:sessions")) { ++ return c.json({ error: "Forbidden: Insufficient scopes" }, 403); ++ } ++ + const targetSessionId = c.req.param("id"); + const { customScopes = [] } = await c.req.json(); + +@@ -168,6 +184,10 @@ sessionRoutes.post("/api/sessions/:id/extend", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + ++ if (!hasScope(auth, "write:sessions")) { ++ return c.json({ error: "Forbidden: Insufficient scopes" }, 403); ++ } ++ + const targetSessionId = c.req.param("id"); + const { extendHours = 1 } = await c.req.json(); + const additionalHours = Math.max(Number(extendHours) || 1, 1); +@@ -215,6 +235,12 @@ sessionRoutes.delete("/api/sessions/:id", async (c) => { + + const targetSessionId = c.req.param("id"); + ++ if (targetSessionId !== auth.sessionId) { ++ if (!hasScope(auth, "write:sessions")) { ++ return c.json({ error: "Forbidden: Insufficient scopes" }, 403); ++ } ++ } ++ + const session = await sqlWrapper.sql` + SELECT id FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId} + `.then((res: any) => res[0]); +diff --git a/tasks/new/2026-0825.01.jul.sec.auth-api.zero-trust-scope-guards-1805.md b/tasks/wip/2026-0825.01.jul.sec.auth-api.zero-trust-scope-guards-1805.md +similarity index 100% +rename from tasks/new/2026-0825.01.jul.sec.auth-api.zero-trust-scope-guards-1805.md +rename to tasks/wip/2026-0825.01.jul.sec.auth-api.zero-trust-scope-guards-1805.md +diff --git a/ui/mod.ts b/ui/mod.ts +index b98b99a..6a758ea 100644 +--- a/ui/mod.ts ++++ b/ui/mod.ts +@@ -9,6 +9,7 @@ import { + getCookieDomain, + isGlobalAdmin, + isSafeRedirectUrl, ++ isSessionAdmin, + } from "../server/auth-session.ts"; + import { auditWrapper } from "../server/audit.ts"; + import { LoginPage } from "./components/LoginPage.tsx"; +@@ -258,8 +259,9 @@ uiApp.get("/admin/users", async (c) => { + } + + const isAdmin = await isGlobalAdmin(auth.userId); +- if (!isAdmin) { +- return c.redirect("/dashboard"); ++ const isSessionAdminRole = await isSessionAdmin(auth); ++ if (!isAdmin || !isSessionAdminRole) { ++ return c.redirect("/dashboard", 302); + } + + const users = await sql` +@@ -286,8 +288,9 @@ uiApp.get("/admin/apps", async (c) => { + } + + const isAdmin = await isGlobalAdmin(auth.userId); +- if (!isAdmin) { +- return c.redirect("/dashboard"); ++ const isSessionAdminRole = await isSessionAdmin(auth); ++ if (!isAdmin || !isSessionAdminRole) { ++ return c.redirect("/dashboard", 302); + } + + const apps = await sql` +@@ -317,8 +320,9 @@ uiApp.get("/admin/roles", async (c) => { + } + + const isAdmin = await isGlobalAdmin(auth.userId); +- if (!isAdmin) { +- return c.redirect("/dashboard"); ++ const isSessionAdminRole = await isSessionAdmin(auth); ++ if (!isAdmin || !isSessionAdminRole) { ++ return c.redirect("/dashboard", 302); + } + + const roles = await sql` +@@ -351,8 +355,9 @@ uiApp.get("/admin/invites", async (c) => { + } + + const isAdmin = await isGlobalAdmin(auth.userId); +- if (!isAdmin) { +- return c.redirect("/dashboard"); ++ const isSessionAdminRole = await isSessionAdmin(auth); ++ if (!isAdmin || !isSessionAdminRole) { ++ return c.redirect("/dashboard", 302); + } + + const invites = await sql` +@@ -419,8 +424,9 @@ uiApp.get("/admin/users/:id", async (c) => { + } + + const isAdmin = await isGlobalAdmin(auth.userId); +- if (!isAdmin) { +- return c.redirect("/admin/users"); ++ const isSessionAdminRole = await isSessionAdmin(auth); ++ if (!isAdmin || !isSessionAdminRole) { ++ return c.redirect("/admin/users", 302); + } + + const targetUserId = c.req.param("id"); +@@ -490,8 +496,9 @@ uiApp.get("/admin/audit-logs", async (c) => { + } + + const isAdmin = await isGlobalAdmin(auth.userId); +- if (!isAdmin) { +- return c.redirect("/dashboard"); ++ const isSessionAdminRole = await isSessionAdmin(auth); ++ if (!isAdmin || !isSessionAdminRole) { ++ return c.redirect("/dashboard", 302); + } + + const logs = await sql` diff --git a/server/auth-session.ts b/server/auth-session.ts index f0814be..6c05e53 100644 --- a/server/auth-session.ts +++ b/server/auth-session.ts @@ -405,6 +405,75 @@ export function isSafeRedirectUrl( /** * Extracts the real client IP from X-Real-IP or X-Forwarded-For headers. */ +/** + * Evaluates if the current user's capabilities satisfy the required scope. + * If !auth.isAgent, primary sessions inherit full capabilities (returns true). + * If auth.isAgent, checks if customScopes contains the requiredScope or '*'. + */ +export function hasScope( + auth: AuthenticatedUser, + requiredScope: string, +): boolean { + if (!auth.isAgent) return true; + if (!Array.isArray(auth.customScopes)) return false; + return auth.customScopes.includes("*") || + auth.customScopes.includes(requiredScope); +} + +/** + * Helper to check if the session itself is authorized as an admin. + */ +export async function isSessionAdmin( + auth: AuthenticatedUser, +): Promise { + const globalAdmin = await isGlobalAdmin(auth.userId); + if (!globalAdmin) return false; + if (!auth.isAgent) return true; + return Array.isArray(auth.customScopes) && auth.customScopes.includes("*"); +} + +/** + * Hono Middleware: Blocks access if the session is a delegated agent session. + */ +export async function requirePrimarySession( + c: Context, + next: () => Promise, +) { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + if (auth.isAgent) { + return c.json({ error: "Forbidden: Primary session required" }, 403); + } + await next(); +} + +/** + * Hono Middleware Factory: Requires a specific scope. + */ +export function requireScope(scope: string) { + return async (c: Context, next: () => Promise) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + if (!hasScope(auth, scope)) { + return c.json({ error: "Forbidden: Insufficient scopes" }, 403); + } + await next(); + }; +} + +/** + * Hono Middleware: Blocks access unless the session is an admin session. + */ +export async function requireAdmin(c: Context, next: () => Promise) { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + const isAdmin = await isSessionAdmin(auth); + if (!isAdmin) { + return c.json({ error: "Forbidden: Global admin access required" }, 403); + } + await next(); +} + export function getClientIp(c: Context): string { const realIp = c.req.header("x-real-ip"); if (realIp) { diff --git a/server/main.test.ts b/server/main.test.ts index d5875b3..201a9eb 100644 --- a/server/main.test.ts +++ b/server/main.test.ts @@ -1330,3 +1330,247 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { }, ); }); + +Deno.test("Zero-Trust Scope Guards", async (t) => { + const mockUserId = "user-id-guards"; + + await t.step( + "Delegated session is blocked from mutating sessions without scope", + async () => { + const mockSessionId = "ay_sess_delegated_123"; + + setMockSql( + (async (strings: any, ..._values: any[]) => { + const q = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (q.includes("SELECT id, is_agent FROM sessions")) { + return [{ id: "other_session", is_agent: false }]; + } + return []; + }) as any, + ); + + if ((valkey as any).get.restore) { + (valkey as any).get.restore(); + } + const valkeyStub = stub( + valkey, + "get", + () => + Promise.resolve( + JSON.stringify({ + uuid: mockUserId, + username: "agent-user", + isAgent: true, + customScopes: ["read:data"], + }), + ), + ); + + try { + const req = new Request( + "http://localhost/api/sessions/other_session/extend", + { + method: "POST", + headers: { + "Authorization": `Bearer ${mockSessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ extendHours: 2 }), + }, + ); + const res = await app.fetch(req); + assertEquals(res.status, 403); + const body = await res.json(); + assertEquals(body.error, "Forbidden: Insufficient scopes"); + } finally { + valkeyStub.restore(); + } + }, + ); + + await t.step("Delegated session can self-revoke", async () => { + const mockSessionId = "ay_sess_delegated_123"; + + setMockSql( + (async (strings: any, ..._values: any[]) => { + const q = Array.isArray(strings) ? strings.join("?") : String(strings); + if (q.includes("SELECT id FROM sessions WHERE id =")) { + return [{ id: mockSessionId }]; + } + if (q.includes("DELETE FROM sessions")) { + return [{ id: mockSessionId }]; + } + return []; + }) as any, + ); + + if ((valkey as any).get.restore) { + (valkey as any).get.restore(); + } + const valkeyStub = stub( + valkey, + "get", + () => + Promise.resolve( + JSON.stringify({ + uuid: mockUserId, + username: "agent-user", + isAgent: true, + customScopes: ["read:data"], + }), + ), + ); + + const delStub = stub(valkey, "del", () => Promise.resolve(1 as any)); + + try { + const req = new Request( + `http://localhost/api/sessions/${mockSessionId}`, + { + method: "DELETE", + headers: { "Authorization": `Bearer ${mockSessionId}` }, + }, + ); + const res = await app.fetch(req); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.success, true); + } finally { + valkeyStub.restore(); + delStub.restore(); + } + }); + + await t.step( + "Delegated session blocked from UI /admin redirect", + async () => { + const mockSessionId = "ay_sess_delegated_123"; + + setMockSql( + (async (strings: any, ..._values: any[]) => { + const q = Array.isArray(strings) + ? strings.join("?") + : String(strings); + if (q.includes("SELECT g.id")) { + return [{ id: "admin-grant-id" }]; + } + return []; + }) as any, + ); + + if ((valkey as any).get.restore) { + (valkey as any).get.restore(); + } + const valkeyStub = stub( + valkey, + "get", + () => + Promise.resolve( + JSON.stringify({ + uuid: mockUserId, + username: "agent-user", + isAgent: true, + customScopes: ["read:data"], + }), + ), + ); + + try { + const req = new Request("http://localhost/admin/users", { + headers: { "Cookie": `session_id=${mockSessionId}` }, + }); + const res = await app.fetch(req); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "/dashboard"); + } finally { + valkeyStub.restore(); + } + }, + ); + + await t.step( + "Delegated session without admin scope blocked from POST /api/sessions/delegate", + async () => { + const mockSessionId = "ay_sess_delegated_123"; + + if ((valkey as any).get.restore) { + (valkey as any).get.restore(); + } + const valkeyStub = stub( + valkey, + "get", + () => + Promise.resolve( + JSON.stringify({ + uuid: mockUserId, + username: "agent-user", + isAgent: true, + customScopes: ["write:sessions"], + }), + ), + ); + + try { + const req = new Request("http://localhost/api/sessions/delegate", { + method: "POST", + headers: { + "Authorization": `Bearer ${mockSessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ label: "Sub Agent" }), + }); + const res = await app.fetch(req); + assertEquals(res.status, 403); + const body = await res.json(); + assertEquals(body.error, "Forbidden: Insufficient scopes"); + } finally { + valkeyStub.restore(); + } + }, + ); + + await t.step( + "Delegated session without write:events blocked from POST /api/events", + async () => { + const mockSessionId = "ay_sess_delegated_123"; + + if ((valkey as any).get.restore) { + (valkey as any).get.restore(); + } + const valkeyStub = stub( + valkey, + "get", + () => + Promise.resolve( + JSON.stringify({ + uuid: mockUserId, + username: "agent-user", + isAgent: true, + customScopes: ["read:data"], + }), + ), + ); + + try { + const req = new Request("http://localhost/api/events", { + method: "POST", + headers: { + "Authorization": `Bearer ${mockSessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "Hacking Workshop" }), + }); + const res = await app.fetch(req); + assertEquals(res.status, 403); + const body = await res.json(); + assertEquals(body.error, "Forbidden: Insufficient scopes"); + } finally { + valkeyStub.restore(); + } + }, + ); + + restoreMockSql(); +}); diff --git a/server/main.ts b/server/main.ts index 169f6e7..cb264ef 100644 --- a/server/main.ts +++ b/server/main.ts @@ -16,6 +16,8 @@ import { extractAllSessionIds, getAuthenticatedUser, isGlobalAdmin, + requireAdmin, + requirePrimarySession, } from "./auth-session.ts"; import { decodeBase64Url, @@ -175,6 +177,7 @@ app.use("/api/register/*", async (c, next) => { }); // Admin Endpoints (/api/admin/*): 60 requests per minute by user_id +app.use("/api/admin/*", requireAdmin); app.use("/api/admin/*", async (c, next) => { const auth = await getAuthenticatedUser(c); if (!auth) { @@ -193,6 +196,9 @@ app.use("/api/admin/*", async (c, next) => { }); // --------------------------------------------------------- +// Primary session requirement for passkeys management to prevent agent mutation +app.use("/api/passkeys/*", requirePrimarySession); + // Provisioning & Registration (Use Cases 1, 2, 3) // --------------------------------------------------------- @@ -200,10 +206,6 @@ app.post("/api/admin/invites/create", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden: Global admin access required" }, 403); - } - const { appId, role, @@ -1048,10 +1050,6 @@ app.get("/api/admin/audit-logs", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden: Global admin access required" }, 403); - } - const logs = await sqlWrapper.sql` SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user FROM audit_records a @@ -1067,10 +1065,6 @@ app.get("/api/admin/users", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden: Global admin access required" }, 403); - } - const users = await sqlWrapper.sql` SELECT id, username, display_name, account_status FROM users @@ -1084,10 +1078,6 @@ app.post("/api/admin/users/:id/status", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden: Global admin access required" }, 403); - } - const targetUserId = c.req.param("id"); const { status } = await c.req.json(); @@ -1114,10 +1104,6 @@ app.post("/api/admin/users/:id/profile", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden: Global admin access required" }, 403); - } - const targetUserId = c.req.param("id"); const { displayName } = await c.req.json(); @@ -1143,9 +1129,6 @@ app.post("/api/admin/users/:id/profile", async (c) => { app.get("/api/admin/apps", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const apps = await sqlWrapper.sql` SELECT a.id, a.name, a.spiffe_id, a.description, a.created_at, @@ -1161,9 +1144,6 @@ app.get("/api/admin/apps", async (c) => { app.post("/api/admin/apps", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const { name, @@ -1207,9 +1187,6 @@ app.post("/api/admin/apps", async (c) => { app.put("/api/admin/apps/:id", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const appId = c.req.param("id"); const { @@ -1253,9 +1230,6 @@ app.put("/api/admin/apps/:id", async (c) => { app.delete("/api/admin/apps/:id", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const appId = c.req.param("id"); const app = await sqlWrapper @@ -1281,9 +1255,6 @@ app.delete("/api/admin/apps/:id", async (c) => { app.get("/api/admin/roles", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const appId = c.req.query("appId"); let roles; @@ -1312,9 +1283,6 @@ app.get("/api/admin/roles", async (c) => { app.post("/api/admin/roles", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const { name, description, appId } = await c.req.json(); if ( @@ -1373,9 +1341,6 @@ app.post("/api/admin/roles", async (c) => { app.put("/api/admin/roles/:id", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const roleId = c.req.param("id"); const { name, description } = await c.req.json(); @@ -1410,9 +1375,6 @@ app.put("/api/admin/roles/:id", async (c) => { app.delete("/api/admin/roles/:id", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const roleId = c.req.param("id"); const role = await sqlWrapper @@ -1445,9 +1407,6 @@ app.delete("/api/admin/roles/:id", async (c) => { app.get("/api/admin/invites", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const invites = await sqlWrapper.sql` SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at, @@ -1464,9 +1423,6 @@ app.get("/api/admin/invites", async (c) => { app.get("/api/admin/invites/:id/redemptions", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const inviteId = c.req.param("id"); const redemptions = await sqlWrapper.sql` @@ -1483,9 +1439,6 @@ app.get("/api/admin/invites/:id/redemptions", async (c) => { app.delete("/api/admin/invites/:id", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const inviteId = c.req.param("id"); const invite = await sqlWrapper @@ -1511,9 +1464,6 @@ app.delete("/api/admin/invites/:id", async (c) => { app.get("/api/admin/users/:id/grants", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const targetUserId = c.req.param("id"); const grants = await sqlWrapper.sql` @@ -1529,9 +1479,6 @@ app.get("/api/admin/users/:id/grants", async (c) => { app.post("/api/admin/users/:id/grants", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const targetUserId = c.req.param("id"); const { appId, role } = await c.req.json(); @@ -1572,9 +1519,6 @@ app.post("/api/admin/users/:id/grants", async (c) => { app.delete("/api/admin/users/:id/grants/:appId", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const { id: targetUserId, appId } = c.req.param(); @@ -1605,9 +1549,6 @@ app.delete("/api/admin/users/:id/grants/:appId", async (c) => { app.get("/api/admin/aaguid", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const allowlist = await sqlWrapper .sql`SELECT id, aaguid, description, created_at FROM aaguid_allowlist ORDER BY created_at DESC`; return c.json({ allowlist }); @@ -1616,9 +1557,6 @@ app.get("/api/admin/aaguid", async (c) => { app.post("/api/admin/aaguid", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const { aaguid, description } = await c.req.json(); const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/i; @@ -1649,9 +1587,6 @@ app.post("/api/admin/aaguid", async (c) => { app.delete("/api/admin/aaguid/:id", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const id = c.req.param("id"); const record = await sqlWrapper .sql`DELETE FROM aaguid_allowlist WHERE id = ${id} RETURNING aaguid` @@ -1675,9 +1610,6 @@ app.delete("/api/admin/aaguid/:id", async (c) => { app.post("/api/admin/hwk", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const { jwk, name } = await c.req.json(); if (!jwk || !name || typeof name !== "string") { @@ -1721,9 +1653,6 @@ app.post("/api/admin/hwk", async (c) => { app.delete("/api/admin/hwk/:fingerprint", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const fingerprint = c.req.param("fingerprint"); @@ -1755,9 +1684,6 @@ app.delete("/api/admin/hwk/:fingerprint", async (c) => { app.get("/api/admin/users/:id", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const targetUserId = c.req.param("id"); const user = await sqlWrapper .sql`SELECT id, username, display_name, account_status FROM users WHERE id = ${targetUserId}` @@ -1773,9 +1699,6 @@ app.get("/api/admin/users/:id", async (c) => { app.delete("/api/admin/sessions/:id", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const sessionId = c.req.param("id"); const session = await sqlWrapper .sql`DELETE FROM sessions WHERE id = ${sessionId} RETURNING user_id` @@ -1800,9 +1723,6 @@ app.delete("/api/admin/sessions/:id", async (c) => { app.delete("/api/admin/users/:id/sessions", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const targetUserId = c.req.param("id"); const sessions = await sqlWrapper .sql`DELETE FROM sessions WHERE user_id = ${targetUserId} RETURNING id`; @@ -1824,9 +1744,6 @@ app.delete("/api/admin/users/:id/sessions", async (c) => { app.delete("/api/admin/users/:userId/passkeys/:passkeyId", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const { userId, passkeyId } = c.req.param(); const passkey = await sqlWrapper .sql`DELETE FROM passkeys WHERE id = ${passkeyId} AND user_id = ${userId} RETURNING id` @@ -1847,9 +1764,6 @@ app.delete("/api/admin/users/:userId/passkeys/:passkeyId", async (c) => { app.post("/api/admin/users/:id/recovery", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - if (!(await isGlobalAdmin(auth.userId))) { - return c.json({ error: "Forbidden" }, 403); - } const targetUserId = c.req.param("id"); const targetUser = await sqlWrapper .sql`SELECT id FROM users WHERE id = ${targetUserId}` diff --git a/server/routes/events.ts b/server/routes/events.ts index ea9944a..c71eacc 100644 --- a/server/routes/events.ts +++ b/server/routes/events.ts @@ -3,7 +3,11 @@ import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie"; import { encodeHex } from "jsr:@std/encoding@1/hex"; import { sqlWrapper } from "../db.ts"; import { valkey } from "../valkey.ts"; -import { getAuthenticatedUser, getCookieDomain } from "../auth-session.ts"; +import { + getAuthenticatedUser, + getCookieDomain, + hasScope, +} from "../auth-session.ts"; import { EventJoinPage } from "../../ui/components/EventJoinPage.tsx"; import { EventSplashPage } from "../../ui/components/EventSplashPage.tsx"; @@ -17,6 +21,10 @@ eventRoutes.post("/api/events/:id/end", async (c) => { const user = await getAuthenticatedUser(c); if (!user) return c.json({ error: "Unauthorized" }, 401); + if (!hasScope(user, "write:events")) { + return c.json({ error: "Forbidden: Insufficient scopes" }, 403); + } + const eventId = c.req.param("id"); try { @@ -57,6 +65,10 @@ eventRoutes.post("/api/events/:id/extend", async (c) => { const user = await getAuthenticatedUser(c); if (!user) return c.json({ error: "Unauthorized" }, 401); + if (!hasScope(user, "write:events")) { + return c.json({ error: "Forbidden: Insufficient scopes" }, 403); + } + const eventId = c.req.param("id"); const body = await c.req.json().catch(() => ({})); const extendHours = Math.max(Number(body.extendHours) || 1, 1); @@ -110,6 +122,10 @@ eventRoutes.post("/api/events", async (c) => { const user = await getAuthenticatedUser(c); if (!user) return c.json({ error: "Unauthorized" }, 401); + if (!hasScope(user, "write:events")) { + return c.json({ error: "Forbidden: Insufficient scopes" }, 403); + } + const body = await c.req.json().catch(() => ({})); const { name, appId, role = "viewer", maxSeats = 50, lifespanHours = 3 } = body; diff --git a/server/routes/sessions.ts b/server/routes/sessions.ts index 11a9cd7..6e16aef 100644 --- a/server/routes/sessions.ts +++ b/server/routes/sessions.ts @@ -2,7 +2,11 @@ import { Hono } from "jsr:@hono/hono@4"; import { sqlWrapper } from "../db.ts"; import { valkey } from "../valkey.ts"; import { auditWrapper } from "../audit.ts"; -import { getAuthenticatedUser, getClientIp } from "../auth-session.ts"; +import { + getAuthenticatedUser, + getClientIp, + hasScope, +} from "../auth-session.ts"; export const sessionRoutes = new Hono(); @@ -32,6 +36,13 @@ sessionRoutes.post("/api/sessions/delegate", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); + // If delegating, the current session must have 'admin' scope or '*' if it's an agent + if (auth.isAgent) { + if (!hasScope(auth, "admin") && !hasScope(auth, "*")) { + return c.json({ error: "Forbidden: Insufficient scopes" }, 403); + } + } + const { label, lifespanHours = 1, @@ -119,6 +130,10 @@ sessionRoutes.put("/api/sessions/:id/scopes", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); + if (!hasScope(auth, "write:sessions")) { + return c.json({ error: "Forbidden: Insufficient scopes" }, 403); + } + const targetSessionId = c.req.param("id"); const { customScopes = [] } = await c.req.json(); @@ -168,6 +183,10 @@ sessionRoutes.post("/api/sessions/:id/extend", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); + if (!hasScope(auth, "write:sessions")) { + return c.json({ error: "Forbidden: Insufficient scopes" }, 403); + } + const targetSessionId = c.req.param("id"); const { extendHours = 1 } = await c.req.json(); const additionalHours = Math.max(Number(extendHours) || 1, 1); @@ -215,6 +234,12 @@ sessionRoutes.delete("/api/sessions/:id", async (c) => { const targetSessionId = c.req.param("id"); + if (targetSessionId !== auth.sessionId) { + if (!hasScope(auth, "write:sessions")) { + return c.json({ error: "Forbidden: Insufficient scopes" }, 403); + } + } + const session = await sqlWrapper.sql` SELECT id FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId} `.then((res: any) => res[0]); diff --git a/tasks/new/2026-0825.01.jul.sec.auth-api.zero-trust-scope-guards-1805.md b/tasks/complete/2026-0825.01.jul.sec.auth-api.zero-trust-scope-guards-1805.md similarity index 100% rename from tasks/new/2026-0825.01.jul.sec.auth-api.zero-trust-scope-guards-1805.md rename to tasks/complete/2026-0825.01.jul.sec.auth-api.zero-trust-scope-guards-1805.md diff --git a/ui/mod.ts b/ui/mod.ts index b98b99a..6a758ea 100644 --- a/ui/mod.ts +++ b/ui/mod.ts @@ -9,6 +9,7 @@ import { getCookieDomain, isGlobalAdmin, isSafeRedirectUrl, + isSessionAdmin, } from "../server/auth-session.ts"; import { auditWrapper } from "../server/audit.ts"; import { LoginPage } from "./components/LoginPage.tsx"; @@ -258,8 +259,9 @@ uiApp.get("/admin/users", async (c) => { } const isAdmin = await isGlobalAdmin(auth.userId); - if (!isAdmin) { - return c.redirect("/dashboard"); + const isSessionAdminRole = await isSessionAdmin(auth); + if (!isAdmin || !isSessionAdminRole) { + return c.redirect("/dashboard", 302); } const users = await sql` @@ -286,8 +288,9 @@ uiApp.get("/admin/apps", async (c) => { } const isAdmin = await isGlobalAdmin(auth.userId); - if (!isAdmin) { - return c.redirect("/dashboard"); + const isSessionAdminRole = await isSessionAdmin(auth); + if (!isAdmin || !isSessionAdminRole) { + return c.redirect("/dashboard", 302); } const apps = await sql` @@ -317,8 +320,9 @@ uiApp.get("/admin/roles", async (c) => { } const isAdmin = await isGlobalAdmin(auth.userId); - if (!isAdmin) { - return c.redirect("/dashboard"); + const isSessionAdminRole = await isSessionAdmin(auth); + if (!isAdmin || !isSessionAdminRole) { + return c.redirect("/dashboard", 302); } const roles = await sql` @@ -351,8 +355,9 @@ uiApp.get("/admin/invites", async (c) => { } const isAdmin = await isGlobalAdmin(auth.userId); - if (!isAdmin) { - return c.redirect("/dashboard"); + const isSessionAdminRole = await isSessionAdmin(auth); + if (!isAdmin || !isSessionAdminRole) { + return c.redirect("/dashboard", 302); } const invites = await sql` @@ -419,8 +424,9 @@ uiApp.get("/admin/users/:id", async (c) => { } const isAdmin = await isGlobalAdmin(auth.userId); - if (!isAdmin) { - return c.redirect("/admin/users"); + const isSessionAdminRole = await isSessionAdmin(auth); + if (!isAdmin || !isSessionAdminRole) { + return c.redirect("/admin/users", 302); } const targetUserId = c.req.param("id"); @@ -490,8 +496,9 @@ uiApp.get("/admin/audit-logs", async (c) => { } const isAdmin = await isGlobalAdmin(auth.userId); - if (!isAdmin) { - return c.redirect("/dashboard"); + const isSessionAdminRole = await isSessionAdmin(auth); + if (!isAdmin || !isSessionAdminRole) { + return c.redirect("/dashboard", 302); } const logs = await sql`