From 41f58561814dc63c435c7486a66098fa4bd743e4 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 2 Sep 2026 00:48:16 -0700 Subject: [PATCH 1/3] refactor: migrate control plane routing to Hono Replace the hand-written HTTP dispatcher with a Hono app fed by a framework-neutral route catalog behind one fail-closed admission pipeline. - keep authentication, RBAC, service grants, sandbox fallback, SCM compatibility, cache policy, and response headers as explicit catalog policy enforced before every handler - audit authorization decisions from the admission layer and dispatch - finalize a service actor's canonical user before RBAC so the user authorized is the user attributed - build the Hono app from a catalog factory so tests compose routes - cover every catalog route through the Worker per credential class Claude-Session: https://claude.ai/code/session_01KdDpTgGEjXpBA9SaGQVUH1 --- package-lock.json | 1 + packages/control-plane/README.md | 13 +- packages/control-plane/package.json | 1 + .../control-plane/src/auth/authenticate.ts | 4 +- .../src/auth/identity-enforcement.test.ts | 163 +-- .../src/auth/request-services.ts | 14 + .../src/auth/service/request-authenticator.ts | 10 +- .../src/http/create-request-context.ts | 28 + .../control-plane/src/http/request-context.ts | 27 + packages/control-plane/src/http/responses.ts | 26 + packages/control-plane/src/index.ts | 7 +- .../src/router.analytics.test.ts | 2 +- .../control-plane/src/router.auth.test.ts | 3 +- .../src/router.authorization-audit.test.ts | 199 +-- .../control-plane/src/router.autofix.test.ts | 2 +- .../src/router.create-session.test.ts | 10 +- .../control-plane/src/router.policy.test.ts | 43 +- .../src/router.scm-credentials.test.ts | 3 +- .../src/router.session-prompt.test.ts | 2 +- .../src/router.spawn-child.test.ts | 2 +- .../control-plane/src/router.test-support.ts | 40 + packages/control-plane/src/router.ts | 1064 ----------------- .../control-plane/src/routes/analytics.ts | 11 +- .../control-plane/src/routes/audit-events.ts | 3 +- packages/control-plane/src/routes/autofix.ts | 3 +- .../src/routes/automations.test.ts | 17 +- .../control-plane/src/routes/automations.ts | 45 +- .../control-plane/src/routes/browser-auth.ts | 3 +- packages/control-plane/src/routes/catalog.ts | 123 ++ .../src/routes/commit-signing.ts | 11 +- .../src/routes/environment-secrets.ts | 9 +- .../control-plane/src/routes/environments.ts | 11 +- .../control-plane/src/routes/image-builds.ts | 17 +- .../src/routes/integration-settings.ts | 23 +- .../src/routes/keyboard-shortcuts.ts | 5 +- .../control-plane/src/routes/mcp-servers.ts | 11 +- .../src/routes/model-preferences.ts | 5 +- .../src/routes/model-provider-accounts.ts | 5 +- packages/control-plane/src/routes/rbac.ts | 12 +- packages/control-plane/src/routes/repos.ts | 9 +- .../control-plane/src/routes/scm-settings.ts | 13 +- packages/control-plane/src/routes/secrets.ts | 13 +- .../src/routes/session-attachments.ts | 7 +- .../src/routes/session-child-spawn.ts | 3 +- .../src/routes/session-children.ts | 9 +- .../src/routes/session-create.ts | 42 +- .../control-plane/src/routes/session-diffs.ts | 11 +- .../control-plane/src/routes/session-index.ts | 9 +- .../src/routes/session-media-stream.ts | 3 +- .../src/routes/session-media-upload.ts | 3 +- .../src/routes/session-prompt.ts | 8 +- .../src/routes/session-pull-requests.ts | 3 +- .../src/routes/session-runtime-proxy.ts | 15 +- .../src/routes/session-skills.ts | 5 +- .../src/routes/session-ws-token.ts | 5 +- packages/control-plane/src/routes/shared.ts | 116 +- .../src/routes/sign-in-providers.ts | 3 +- packages/control-plane/src/routes/skills.ts | 33 +- .../control-plane/src/routing/hono-app.ts | 162 +++ .../{auth => routing}/identity-enforcement.ts | 131 +- .../src/routing/request-lifecycle.test.ts | 161 +++ .../src/routing/request-lifecycle.ts | 84 ++ .../src/routing/route-admission.ts | 772 ++++++++++++ .../src/routing/route-dispatch.ts | 92 ++ .../src/webhooks/automation-event.ts | 3 +- .../src/webhooks/automation-webhook.ts | 3 +- packages/control-plane/src/webhooks/github.ts | 10 +- packages/control-plane/src/webhooks/sentry.ts | 3 +- ...ono-route-catalog-conformance.test.ts.snap | 177 +++ .../route-admission-matrix.test.ts.snap | 368 ++++++ .../integration/auth-sign-in-claim.test.ts | 9 +- .../integration/browser-auth-callback.test.ts | 9 +- .../integration/browser-auth-router.test.ts | 9 +- .../hono-route-catalog-conformance.test.ts | 76 ++ .../response-compatibility.test.ts | 313 +++++ .../route-admission-matrix.test.ts | 269 +++++ .../integration/routing-compatibility.test.ts | 110 ++ .../test/integration/service-auth.test.ts | 400 ++++++- .../worker-lifecycle-boundary.test.ts | 155 +++ packages/shared/src/types/session-api.ts | 7 +- 80 files changed, 3844 insertions(+), 1767 deletions(-) create mode 100644 packages/control-plane/src/auth/request-services.ts create mode 100644 packages/control-plane/src/http/create-request-context.ts create mode 100644 packages/control-plane/src/http/request-context.ts create mode 100644 packages/control-plane/src/http/responses.ts delete mode 100644 packages/control-plane/src/router.ts create mode 100644 packages/control-plane/src/routes/catalog.ts create mode 100644 packages/control-plane/src/routing/hono-app.ts rename packages/control-plane/src/{auth => routing}/identity-enforcement.ts (64%) create mode 100644 packages/control-plane/src/routing/request-lifecycle.test.ts create mode 100644 packages/control-plane/src/routing/request-lifecycle.ts create mode 100644 packages/control-plane/src/routing/route-admission.ts create mode 100644 packages/control-plane/src/routing/route-dispatch.ts create mode 100644 packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap create mode 100644 packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap create mode 100644 packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts create mode 100644 packages/control-plane/test/integration/response-compatibility.test.ts create mode 100644 packages/control-plane/test/integration/route-admission-matrix.test.ts create mode 100644 packages/control-plane/test/integration/routing-compatibility.test.ts create mode 100644 packages/control-plane/test/integration/worker-lifecycle-boundary.test.ts diff --git a/package-lock.json b/package-lock.json index 3c0cb3a554..5e99d9395c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17783,6 +17783,7 @@ "@cloudflare/workers-types": "^4.20241230.0", "@open-inspect/shared": "file:../shared", "better-auth": "1.6.25", + "hono": "^4.13.0", "yaml": "^2.9.0", "zod": "^4.4.3" }, diff --git a/packages/control-plane/README.md b/packages/control-plane/README.md index 0f8ef75daf..1f05c1ecda 100644 --- a/packages/control-plane/README.md +++ b/packages/control-plane/README.md @@ -1,6 +1,7 @@ # Open-Inspect Control Plane -Cloudflare Workers + Durable Objects control plane for session management and real-time streaming. +Cloudflare Workers + Hono + Durable Objects control plane for session management and real-time +streaming. ## Overview @@ -21,8 +22,8 @@ The control plane provides: ┌─────────────────────────────────────────────────────────────────┐ │ Cloudflare Workers │ │ ┌──────────────────────────────────────────────────────────┐ │ -│ │ API Gateway (router.ts) │ │ -│ │ POST /sessions │ GET /sessions/:id │ WebSocket │ │ +│ │ Hono HTTP API + Route Admission │ │ +│ │ POST /sessions │ GET /sessions/:id │ WebSocket* │ │ │ └─────────────────────────────┬────────────────────────────┘ │ │ │ │ │ ┌─────────────────────────────┴────────────────────────────┐ │ @@ -45,6 +46,12 @@ The control plane provides: └─────────────────────────────────────────────────────────────────┘ ``` +Hono selects ordinary HTTP routes from the framework-neutral catalog. Authentication, service +principal admission, canonical actor resolution, RBAC, sandbox capabilities, and route-specific +authorization remain in the shared admission layer. WebSocket upgrades (`*` above), scheduled +events, Queues, and Durable Object lifecycle callbacks stay at the Cloudflare Worker boundary and do +not pass through Hono. + ## API Endpoints ### Health diff --git a/packages/control-plane/package.json b/packages/control-plane/package.json index 0ed5fbebe6..e0b1247a80 100644 --- a/packages/control-plane/package.json +++ b/packages/control-plane/package.json @@ -17,6 +17,7 @@ "@cloudflare/workers-types": "^4.20241230.0", "@open-inspect/shared": "file:../shared", "better-auth": "1.6.25", + "hono": "^4.13.0", "yaml": "^2.9.0", "zod": "^4.4.3" }, diff --git a/packages/control-plane/src/auth/authenticate.ts b/packages/control-plane/src/auth/authenticate.ts index c237dbdd34..123f2adda1 100644 --- a/packages/control-plane/src/auth/authenticate.ts +++ b/packages/control-plane/src/auth/authenticate.ts @@ -15,7 +15,7 @@ import { authenticateSession, SessionIntegrityError } from "./user/session-authe import { isAuthError, type AuthResult } from "./result"; import { authenticateServiceRequest } from "./service/request-authenticator"; import { createLogger } from "../logger"; -import type { RequestContext } from "../routes/shared"; +import type { AuthenticationRequestServices } from "./request-services"; import type { Env } from "../types"; const logger = createLogger("auth"); @@ -34,7 +34,7 @@ export interface AuthenticationRequirement { export async function authenticate( request: Request, env: Env, - ctx: RequestContext, + ctx: AuthenticationRequestServices, requirement: AuthenticationRequirement = {} ): Promise { const signatureHeader = request.headers.get(SERVICE_SIGNATURE_HEADER); diff --git a/packages/control-plane/src/auth/identity-enforcement.test.ts b/packages/control-plane/src/auth/identity-enforcement.test.ts index 466f4d19e6..450a161a54 100644 --- a/packages/control-plane/src/auth/identity-enforcement.test.ts +++ b/packages/control-plane/src/auth/identity-enforcement.test.ts @@ -4,10 +4,9 @@ import { applyIdentityEnforcement, deriveIdentity, mayAttachCallbackContext, - resolveCanonicalUserId, -} from "./identity-enforcement"; + requireAdmittedCanonicalUserId, +} from "../routing/identity-enforcement"; import type { Principal, ResolvedIdentity } from "./principal"; -import type { UserStore } from "../db/user-store"; import type { RequestContext } from "../routes/shared"; import { TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support"; @@ -43,12 +42,6 @@ function createCtx(principal?: Principal): RequestContext { } as unknown as RequestContext; } -function loggedEvents(spy: { mock: { calls: unknown[][] } }): Array> { - return spy.mock.calls.map( - ([message]: unknown[]) => JSON.parse(String(message)) as Record - ); -} - afterEach(() => { vi.restoreAllMocks(); }); @@ -185,145 +178,45 @@ describe("applyIdentityEnforcement — requires-user rejection", () => { }); }); -describe("resolveCanonicalUserId", () => { - const display = { displayName: "Dana", email: "d@example.com" }; - - it("returns the canonical id directly when the principal already resolved", async () => { - const userStore = { resolveOrCreateUser: vi.fn() } as unknown as UserStore; - const result = await resolveCanonicalUserId( - userStore, - createCtx(USER_PRINCIPAL), - { - participantUserId: "canon-1", - canonicalUserId: "canon-1", - actor: null, - spawnSource: "user", - }, - display - ); - expect(result).toEqual({ userId: "canon-1" }); - }); - - it("creates the user from the VERIFIED actor when unseen", async () => { - const resolveOrCreateUser = vi.fn(async () => ({ id: "canon-new" })); - const userStore = { resolveOrCreateUser } as unknown as UserStore; - const result = await resolveCanonicalUserId( - userStore, - createCtx(SLACK_BOT_PRINCIPAL), - { - participantUserId: "slack:U0123", - canonicalUserId: null, - actor: SLACK_ACTOR, - spawnSource: "slack-bot", - }, - display - ); - expect(result).toEqual({ userId: "canon-new" }); - expect(resolveOrCreateUser).toHaveBeenCalledWith( - expect.objectContaining({ provider: "slack", providerUserId: "U0123", displayName: "Dana" }) - ); - }); +describe("requireAdmittedCanonicalUserId", () => { + const enforced = { + participantUserId: "canon-1", + canonicalUserId: "canon-1", + actor: null, + spawnSource: "user" as const, + }; - it("rejects when actor enrichment relinks to a different authorized user", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - const ctx = createCtx(SLACK_BOT_PRINCIPAL); + it("returns the canonical id only when it matches the admitted authorization subject", () => { + const ctx = createCtx(USER_PRINCIPAL); ctx.authorization = { - userId: "canon-provisional", + userId: "canon-1", suspendedAt: null, + role: { id: "role_builtin_member", key: "member", name: "Member" }, permissions: ["sessions.create"], - role: { id: "role-member", key: "member", name: "Member" }, }; - const result = await resolveCanonicalUserId( - { - resolveOrCreateUser: vi.fn(async () => ({ id: "canon-existing" })), - } as unknown as UserStore, - ctx, - { - participantUserId: "slack:U0123", - canonicalUserId: null, - actor: SLACK_ACTOR, - spawnSource: "slack-bot", - }, - display - ); - expect(result).toBeInstanceOf(Response); - expect((result as Response).status).toBe(409); - await expect((result as Response).json()).resolves.toMatchObject({ - code: "actor_identity_changed", - }); - expect(loggedEvents(warn)).toContainEqual( - expect.objectContaining({ - event: "identity.mismatch_rejected", - expected: "canon-provisional", - actual: "canon-existing", - }) - ); + expect(requireAdmittedCanonicalUserId(ctx, enforced)).toBe("canon-1"); }); - it("rejects a canonical identity whose workspace access is suspended", async () => { + it.each([ + ["a missing canonical subject", { ...enforced, canonicalUserId: null }, "canon-1"], + ["a different admitted subject", enforced, "canon-other"], + ])("fails closed for %s", async (_case, identity, authorizedUserId) => { + vi.spyOn(console, "error").mockImplementation(() => undefined); const ctx = createCtx(USER_PRINCIPAL); - const statement = { - bind: vi.fn(() => statement), - first: vi.fn(async () => null), + ctx.authorization = { + userId: authorizedUserId, + suspendedAt: null, + role: { id: "role_builtin_member", key: "member", name: "Member" }, + permissions: ["sessions.create"], }; - ctx.db = { prepare: vi.fn(() => statement) } as never; - - const result = await resolveCanonicalUserId( - { resolveOrCreateUser: vi.fn() } as unknown as UserStore, - ctx, - { - participantUserId: "canon-1", - canonicalUserId: "canon-1", - actor: null, - spawnSource: "user", - }, - display - ); - - expect(result).toBeInstanceOf(Response); - expect((result as Response).status).toBe(403); - }); - it("fails closed with a 500 if a participant ever lacks both a canonical user and an actor", async () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - const userStore = { resolveOrCreateUser: vi.fn() } as unknown as UserStore; - const result = await resolveCanonicalUserId( - userStore, - createCtx(SLACK_BOT_PRINCIPAL), - { - participantUserId: "slack:U0123", - canonicalUserId: null, - actor: null, - spawnSource: "slack-bot", - }, - display - ); - expect(result).toBeInstanceOf(Response); - expect((result as Response).status).toBe(500); - expect(userStore.resolveOrCreateUser).not.toHaveBeenCalled(); - }); - - it("fails closed with a 500 when resolution throws", async () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - const userStore = { - resolveOrCreateUser: vi.fn(async () => { - throw new Error("d1 down"); - }), - } as unknown as UserStore; - const result = await resolveCanonicalUserId( - userStore, - createCtx(SLACK_BOT_PRINCIPAL), - { - participantUserId: "slack:U0123", - canonicalUserId: null, - actor: SLACK_ACTOR, - spawnSource: "slack-bot", - }, - display - ); + const result = requireAdmittedCanonicalUserId(ctx, identity); expect(result).toBeInstanceOf(Response); expect((result as Response).status).toBe(500); + await expect((result as Response).json()).resolves.toEqual({ + error: "Failed to resolve session identity", + }); }); }); diff --git a/packages/control-plane/src/auth/request-services.ts b/packages/control-plane/src/auth/request-services.ts new file mode 100644 index 0000000000..6113988bb7 --- /dev/null +++ b/packages/control-plane/src/auth/request-services.ts @@ -0,0 +1,14 @@ +import type { BetterAuthRuntime } from "./user/runtime"; +import type { SqlDatabase } from "../db/sql-database"; +import type { CorrelationContext } from "../logger"; + +/** + * Narrow request-scoped capabilities required by authentication. + * + * Core authentication deliberately depends on this auth-owned port instead + * of the aggregate route/admission context. + */ +export interface AuthenticationRequestServices extends CorrelationContext { + db: SqlDatabase; + getUserAuth?: () => BetterAuthRuntime; +} diff --git a/packages/control-plane/src/auth/service/request-authenticator.ts b/packages/control-plane/src/auth/service/request-authenticator.ts index 44fbc043dd..113d6fe7b4 100644 --- a/packages/control-plane/src/auth/service/request-authenticator.ts +++ b/packages/control-plane/src/auth/service/request-authenticator.ts @@ -11,9 +11,9 @@ import { readBodyCapped } from "@open-inspect/shared/http-body"; import { TOKEN_VALIDITY_MS } from "@open-inspect/shared/auth"; import { UserStore } from "../../db/user-store"; import { createLogger } from "../../logger"; -import type { RequestContext } from "../../routes/shared"; import type { Env } from "../../types"; import { ASSERTION_RIGHTS, isActorNamespace, type ActorNamespace } from "../principal"; +import type { AuthenticationRequestServices } from "../request-services"; import type { AuthResult } from "../result"; import { serviceAuthSecret } from "./config"; @@ -43,7 +43,11 @@ function parseActor(actor: string): { provider: ActorNamespace; providerUserId: const seenNonces = new Map(); const SEEN_NONCE_LIMIT = 5000; -function recordNonce(service: ServiceName, nonce: string, ctx: RequestContext): void { +function recordNonce( + service: ServiceName, + nonce: string, + ctx: AuthenticationRequestServices +): void { const now = Date.now(); const key = `${service}:${nonce}`; const expiresAt = seenNonces.get(key); @@ -73,7 +77,7 @@ function recordNonce(service: ServiceName, nonce: string, ctx: RequestContext): export async function authenticateServiceRequest( request: Request, env: Env, - ctx: RequestContext, + ctx: AuthenticationRequestServices, signatureHeader: string ): Promise { const serviceHeader = request.headers.get(SERVICE_HEADER) ?? ""; diff --git a/packages/control-plane/src/http/create-request-context.ts b/packages/control-plane/src/http/create-request-context.ts new file mode 100644 index 0000000000..f79b57f3e1 --- /dev/null +++ b/packages/control-plane/src/http/create-request-context.ts @@ -0,0 +1,28 @@ +import { getUserAuth, getUserAuthRuntime } from "../auth/user/runtime"; +import { createRequestMetrics, instrumentD1 } from "../db/instrumented-d1"; +import type { SqlDatabase } from "../db/sql-database"; +import type { BackgroundTasks } from "../platform-ports"; +import type { Env } from "../types"; +import type { RequestContext } from "./request-context"; + +/** Assemble framework-neutral per-request state after the DB guard passes. */ +export function createRequestContext(input: { + request: Request; + env: Env; + database: SqlDatabase; + executionCtx: BackgroundTasks; +}): RequestContext { + const { request, env, database, executionCtx } = input; + const metrics = createRequestMetrics(); + + return { + trace_id: request.headers.get("x-trace-id") || crypto.randomUUID(), + request_id: crypto.randomUUID().slice(0, 8), + metrics, + db: instrumentD1(database, metrics), + // The stable uninstrumented binding remains the Better Auth cache key. + getUserAuth: () => getUserAuth(env, database), + getUserAuthRuntime: () => getUserAuthRuntime(env, database), + executionCtx, + }; +} diff --git a/packages/control-plane/src/http/request-context.ts b/packages/control-plane/src/http/request-context.ts new file mode 100644 index 0000000000..1690c6a634 --- /dev/null +++ b/packages/control-plane/src/http/request-context.ts @@ -0,0 +1,27 @@ +import type { EffectiveAuthorization } from "@open-inspect/shared/rbac"; +import type { AuthenticationContext, Principal } from "../auth/principal"; +import type { AuthenticationRequestServices } from "../auth/request-services"; +import type { UserAuthRuntime } from "../auth/user/runtime"; +import type { AutomationRow } from "../db/automation-store"; +import type { RequestMetrics } from "../db/instrumented-d1"; +import type { BackgroundTasks } from "../platform-ports"; + +/** Automation resource admitted for the current mutation. */ +export interface AutomationRouteAdmission { + automation: AutomationRow; +} + +/** + * Framework-neutral aggregate state assembled at the HTTP composition root. + * Authentication consumes only its narrower AuthenticationRequestServices + * projection, preventing auth from depending on route or Hono contracts. + */ +export type RequestContext = AuthenticationRequestServices & { + metrics: RequestMetrics; + executionCtx: BackgroundTasks; + getUserAuthRuntime?: () => UserAuthRuntime; + principal?: Principal; + authentication?: AuthenticationContext; + authorization?: EffectiveAuthorization; + automationAdmission?: AutomationRouteAdmission; +}; diff --git a/packages/control-plane/src/http/responses.ts b/packages/control-plane/src/http/responses.ts new file mode 100644 index 0000000000..970898a5ce --- /dev/null +++ b/packages/control-plane/src/http/responses.ts @@ -0,0 +1,26 @@ +/** Create a JSON response without framework-added content-type parameters. */ +export function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** Create the control plane's standard JSON error envelope. */ +export function error(message: string, status = 400): Response { + return json({ error: message }, status); +} + +/** + * Raise from a route handler or helper to request a specific HTTP response. + * The route handler boundary maps this without exposing framework errors. + */ +export class HttpError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + this.name = "HttpError"; + } +} diff --git a/packages/control-plane/src/index.ts b/packages/control-plane/src/index.ts index 596bab8ae9..222d108e64 100644 --- a/packages/control-plane/src/index.ts +++ b/packages/control-plane/src/index.ts @@ -4,7 +4,7 @@ * Cloudflare Workers entry point with Durable Objects for session management. */ -import { handleRequest } from "./router"; +import { handleControlPlaneHttp } from "./routing/hono-app"; import { createLogger } from "./logger"; import type { Env } from "./types"; import type { GitHubAutofixEnvelope } from "@open-inspect/shared"; @@ -42,8 +42,9 @@ export default { return handleWebSocket(request, env, url, db, metrics); } - // Regular API request — logged by the router with requestId and timing - return handleRequest(request, env, createCloudflareBackgroundTasks(ctx)); + // Regular API request — Hono owns HTTP route selection while the neutral + // admission/dispatch pipeline retains authentication and authorization. + return handleControlPlaneHttp(request, env, ctx); }, /** diff --git a/packages/control-plane/src/router.analytics.test.ts b/packages/control-plane/src/router.analytics.test.ts index d48370d6b3..349671959b 100644 --- a/packages/control-plane/src/router.analytics.test.ts +++ b/packages/control-plane/src/router.analytics.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { handleRequest } from "./router"; import { + handleRequest, signedServiceRequest, TEST_BACKGROUND_TASK_CONTEXT, TEST_SERVICE_SECRETS, diff --git a/packages/control-plane/src/router.auth.test.ts b/packages/control-plane/src/router.auth.test.ts index f32383ba14..a6aed58db6 100644 --- a/packages/control-plane/src/router.auth.test.ts +++ b/packages/control-plane/src/router.auth.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { handleRequest, routes } from "./router"; +import { routes } from "./routes/catalog"; import { + handleRequest, signedServiceRequest, TEST_BACKGROUND_TASK_CONTEXT, TEST_SERVICE_SECRETS, diff --git a/packages/control-plane/src/router.authorization-audit.test.ts b/packages/control-plane/src/router.authorization-audit.test.ts index cbeab3c4ee..6495c9f3cd 100644 --- a/packages/control-plane/src/router.authorization-audit.test.ts +++ b/packages/control-plane/src/router.authorization-audit.test.ts @@ -1,10 +1,10 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; import type * as AuthenticateModule from "./auth/authenticate"; import type { Principal } from "./auth/principal"; import type { SqlDatabase, SqlStatement } from "./db/sql-database"; -import { handleRequest, routes } from "./router"; import { + defineRoute, json, GITHUB_SANDBOX_FALLBACK_ROUTE, permissionRequirement, @@ -14,7 +14,7 @@ import { serviceAuthorized, type Route, } from "./routes/shared"; -import { TEST_BACKGROUND_TASK_CONTEXT } from "./router.test-support"; +import { createTestRequestHandler, TEST_BACKGROUND_TASK_CONTEXT } from "./router.test-support"; const mocks = vi.hoisted(() => ({ authenticate: vi.fn() })); @@ -24,98 +24,107 @@ vi.mock("./auth/authenticate", async (importOriginal) => ({ })); const TEST_ROUTES: Route[] = [ - { - authentication: { kind: "user-or-service" }, - supportedScmProviders: "all", - method: "POST", - pattern: /^\/audit-test\/actorless-service$/, - authorization: requirePermission("sessions.lifecycle", { - actorlessGrants: [{ service: "github-bot" }], - }), - handler: async () => json({ handled: true }), - }, - { - authentication: { kind: "user" }, - supportedScmProviders: "all", - method: "POST", - pattern: /^\/audit-test\/user-only$/, - authorization: requirePermission("workspace.members.manage"), - handler: async () => json({ handled: true }), - }, - { - authentication: { kind: "user-or-service" }, - supportedScmProviders: "all", - method: "POST", - pattern: /^\/audit-test\/automations\/(?[^/]+)\/pause$/, - authorization: requireAutomation("manage"), - handler: async () => json({ handled: true }), - }, - { - authentication: { kind: "user-or-service" }, - supportedScmProviders: "all", - method: "POST", - pattern: /^\/audit-test\/managed$/, - authorization: requirePermission("workspace.members.manage"), - handler: async () => json({ handled: true }, 201), - }, - { - authentication: { kind: "user-or-service" }, - supportedScmProviders: "all", - method: "GET", - pattern: /^\/audit-test\/managed$/, - authorization: requirePermission("workspace.members.manage"), - handler: async () => json({ handled: true }), - }, - { - authentication: { kind: "user-or-service" }, - supportedScmProviders: "all", - method: "GET", - pattern: /^\/audit-test\/profiles$/, - authorization: requirePermission("skill_profiles.manage_own"), - handler: async () => json({ handled: true }), - }, - { - authentication: { kind: "user-or-service" }, - supportedScmProviders: "all", - method: "GET", - pattern: /^\/audit-test\/read$/, - authorization: requirePermission("workspace.roles.read"), - handler: async () => json({ handled: true }), - }, - { - authentication: { kind: "user-or-service" }, - supportedScmProviders: "all", - method: "POST", - pattern: /^\/audit-test\/service-actor$/, - authorization: requirePermission("sessions.lifecycle"), - handler: async () => json({ handled: true }, 201), - }, - { - authentication: { kind: "service" }, - supportedScmProviders: "all", - method: "POST", - pattern: /^\/audit-test\/service$/, - authorization: serviceAuthorized("github-bot", "required"), - handler: async () => json({ handled: true }), - }, - { - authentication: { kind: "user-or-service" }, - supportedScmProviders: "all", - method: "POST", - pattern: /^\/audit-test\/multi$/, - authorization: requireAll( - permissionRequirement("analytics.read"), - permissionRequirement("workspace.members.manage") - ), - handler: async () => json({ handled: true }), - }, - { - ...GITHUB_SANDBOX_FALLBACK_ROUTE, + defineRoute( + { authentication: { kind: "user-or-service" }, supportedScmProviders: "all" }, + { + method: "POST", + path: "/audit-test/actorless-service", + authorization: requirePermission("sessions.lifecycle", { + actorlessGrants: [{ service: "github-bot" }], + }), + handler: async () => json({ handled: true }), + } + ), + defineRoute( + { authentication: { kind: "user" }, supportedScmProviders: "all" }, + { + method: "POST", + path: "/audit-test/user-only", + authorization: requirePermission("workspace.members.manage"), + handler: async () => json({ handled: true }), + } + ), + defineRoute( + { authentication: { kind: "user-or-service" }, supportedScmProviders: "all" }, + { + method: "POST", + path: "/audit-test/automations/:id/pause", + authorization: requireAutomation("manage"), + handler: async () => json({ handled: true }), + } + ), + defineRoute( + { authentication: { kind: "user-or-service" }, supportedScmProviders: "all" }, + { + method: "POST", + path: "/audit-test/managed", + authorization: requirePermission("workspace.members.manage"), + handler: async () => json({ handled: true }, 201), + } + ), + defineRoute( + { authentication: { kind: "user-or-service" }, supportedScmProviders: "all" }, + { + method: "GET", + path: "/audit-test/managed", + authorization: requirePermission("workspace.members.manage"), + handler: async () => json({ handled: true }), + } + ), + defineRoute( + { authentication: { kind: "user-or-service" }, supportedScmProviders: "all" }, + { + method: "GET", + path: "/audit-test/profiles", + authorization: requirePermission("skill_profiles.manage_own"), + handler: async () => json({ handled: true }), + } + ), + defineRoute( + { authentication: { kind: "user-or-service" }, supportedScmProviders: "all" }, + { + method: "GET", + path: "/audit-test/read", + authorization: requirePermission("workspace.roles.read"), + handler: async () => json({ handled: true }), + } + ), + defineRoute( + { authentication: { kind: "user-or-service" }, supportedScmProviders: "all" }, + { + method: "POST", + path: "/audit-test/service-actor", + authorization: requirePermission("sessions.lifecycle"), + handler: async () => json({ handled: true }, 201), + } + ), + defineRoute( + { authentication: { kind: "service" }, supportedScmProviders: "all" }, + { + method: "POST", + path: "/audit-test/service", + authorization: serviceAuthorized("github-bot", "required"), + handler: async () => json({ handled: true }), + } + ), + defineRoute( + { authentication: { kind: "user-or-service" }, supportedScmProviders: "all" }, + { + method: "POST", + path: "/audit-test/multi", + authorization: requireAll( + permissionRequirement("analytics.read"), + permissionRequirement("workspace.members.manage") + ), + handler: async () => json({ handled: true }), + } + ), + defineRoute(GITHUB_SANDBOX_FALLBACK_ROUTE, { method: "POST", - pattern: /^\/audit-test\/sessions\/(?[^/]+)\/upload$/, + path: "/audit-test/sessions/:id/upload", authorization: requirePermission("sessions.collaborate"), handler: async () => json({ handled: true }, 201), - }, + }), ]; interface AuditWrite { @@ -214,11 +223,7 @@ function auditRecord(write: AuditWrite) { }; } -beforeAll(() => routes.push(...TEST_ROUTES)); - -afterAll(() => { - routes.splice(routes.length - TEST_ROUTES.length, TEST_ROUTES.length); -}); +const handleRequest = createTestRequestHandler(TEST_ROUTES); beforeEach(() => { mocks.authenticate.mockReset(); diff --git a/packages/control-plane/src/router.autofix.test.ts b/packages/control-plane/src/router.autofix.test.ts index 402dea6c0f..fac9af278c 100644 --- a/packages/control-plane/src/router.autofix.test.ts +++ b/packages/control-plane/src/router.autofix.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { handleRequest } from "./router"; import { + handleRequest, signedServiceRequest, TEST_BACKGROUND_TASK_CONTEXT, TEST_SERVICE_SECRETS, diff --git a/packages/control-plane/src/router.create-session.test.ts b/packages/control-plane/src/router.create-session.test.ts index 1e27d75a6a..17294dbd0a 100644 --- a/packages/control-plane/src/router.create-session.test.ts +++ b/packages/control-plane/src/router.create-session.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { generateEncryptionKey } from "./auth/crypto"; import { SessionIndexStore } from "./db/session-index"; import { UserStore } from "./db/user-store"; -import { handleRequest } from "./router"; import { + handleRequest, signedServiceRequest, TEST_BACKGROUND_TASK_CONTEXT, TEST_SERVICE_SECRETS, @@ -153,10 +153,14 @@ describe("handleCreateSession D1 ordering", () => { DB: { prepare: vi.fn((sql: string) => { if (sql.includes("FROM users u") && sql.includes("user_role_assignments")) { + let authorizedUserId = "user-1"; const authorizationStatement = { - bind: vi.fn(() => authorizationStatement), + bind: vi.fn((userId: string) => { + authorizedUserId = userId; + return authorizationStatement; + }), first: vi.fn(async () => ({ - user_id: "user-1", + user_id: authorizedUserId, suspended_at: null, role_id: "role-1", role_key: null, diff --git a/packages/control-plane/src/router.policy.test.ts b/packages/control-plane/src/router.policy.test.ts index f640b38db9..66aad9bd15 100644 --- a/packages/control-plane/src/router.policy.test.ts +++ b/packages/control-plane/src/router.policy.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { enforceRoutePrincipal, handleRequest, routes } from "./router"; -import { TEST_BACKGROUND_TASK_CONTEXT } from "./router.test-support"; +import { enforceRoutePrincipal } from "./routing/route-admission"; +import { routes } from "./routes/catalog"; +import { handleRequest, TEST_BACKGROUND_TASK_CONTEXT } from "./router.test-support"; +import { parsePattern } from "./routes/shared"; import { serviceAllowsPermission } from "./authorization/service-permissions"; import { SCOPED_PERMISSION_PAIRS } from "@open-inspect/shared/rbac"; @@ -9,6 +11,43 @@ function routeFor(method: string, path: string) { } describe("route policy table", () => { + it("publishes the complete canonical route catalog", () => { + expect(routes).toHaveLength(171); + + const paths = routes.map((route) => route.path); + expect(new Set(paths).size).toBe(130); + expect(new Set(routes.map((route) => `${route.method}:${route.path}`)).size).toBe(171); + + for (const route of routes) { + expect(route.pattern.source).toBe(parsePattern(route.path).source); + } + }); + + it("declares every path in the literal-or-parameter grammar shared by Hono and parsePattern", () => { + // Hono gives `*`, `?`, `{...}` and `.` routing meaning that parsePattern + // compiles as literals, so a path outside this grammar would be selected + // by Hono and then rejected by the raw-path regex. + for (const route of routes) { + expect(route.path, `${route.method} ${route.path}`).toMatch(/^(\/([A-Za-z0-9_-]+|:\w+))+$/); + } + }); + + it.each([ + ["GET", "/sessions/inbox", "/sessions/:id"], + ["GET", "/model-provider-accounts/legacy-credentials", "/model-provider-accounts/:id"], + ])("orders the static overlap %s %s before %s", (method, staticPath, dynamicPath) => { + const staticIndex = routes.findIndex( + (route) => route.method === method && route.path === staticPath + ); + const dynamicIndex = routes.findIndex( + (route) => route.method === method && route.path === dynamicPath + ); + + expect(staticIndex).toBeGreaterThanOrEqual(0); + expect(dynamicIndex).toBeGreaterThanOrEqual(0); + expect(staticIndex).toBeLessThan(dynamicIndex); + }); + it("has complete metadata", () => { expect(routes.length).toBeGreaterThan(0); expect( diff --git a/packages/control-plane/src/router.scm-credentials.test.ts b/packages/control-plane/src/router.scm-credentials.test.ts index 85e8ddde33..289f2beca4 100644 --- a/packages/control-plane/src/router.scm-credentials.test.ts +++ b/packages/control-plane/src/router.scm-credentials.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import { handleRequest, routes } from "./router"; +import { routes } from "./routes/catalog"; import { + handleRequest, signedServiceRequest, TEST_BACKGROUND_TASK_CONTEXT, TEST_SERVICE_SECRETS, diff --git a/packages/control-plane/src/router.session-prompt.test.ts b/packages/control-plane/src/router.session-prompt.test.ts index 88fffb4996..748fed0ef5 100644 --- a/packages/control-plane/src/router.session-prompt.test.ts +++ b/packages/control-plane/src/router.session-prompt.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { UserStore } from "./db/user-store"; import { resolveGitHubEnrichmentForRequest } from "./session/identity"; -import { handleRequest } from "./router"; import { + handleRequest, signedServiceRequest, TEST_BACKGROUND_TASK_CONTEXT, TEST_SERVICE_SECRETS, diff --git a/packages/control-plane/src/router.spawn-child.test.ts b/packages/control-plane/src/router.spawn-child.test.ts index 103ec2d117..60ba676be8 100644 --- a/packages/control-plane/src/router.spawn-child.test.ts +++ b/packages/control-plane/src/router.spawn-child.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { handleRequest } from "./router"; import { + handleRequest, signedServiceRequest, TEST_BACKGROUND_TASK_CONTEXT, TEST_SERVICE_SECRETS, diff --git a/packages/control-plane/src/router.test-support.ts b/packages/control-plane/src/router.test-support.ts index cba9fb5aa5..3b58f78e6a 100644 --- a/packages/control-plane/src/router.test-support.ts +++ b/packages/control-plane/src/router.test-support.ts @@ -9,11 +9,51 @@ import { buildServiceAuthHeaders, type ServiceName } from "@open-inspect/shared/service-auth"; import type { BackgroundTasks } from "./platform-ports"; import { createTestBackgroundTasks } from "./background-tasks.test-support"; +import { + createControlPlaneHttpHandler, + handleControlPlaneHttp, + type ControlPlaneHttpHandler, +} from "./routing/hono-app"; +import type { Route } from "./routes/shared"; +import type { Env } from "./types"; // The single contract-faithful double lives in background-tasks.test-support; // this shared instance's recordings are unused by the router suites. export const TEST_BACKGROUND_TASK_CONTEXT: BackgroundTasks = createTestBackgroundTasks(); +function executionContextFromBackgroundTasks(tasks: BackgroundTasks): ExecutionContext { + return { + waitUntil(promise): void { + tasks.submit(() => promise, { name: "test.http.request" }); + }, + passThroughOnException(): void {}, + } as ExecutionContext; +} + +/** Request handler signature used by unit fixtures that provide the platform-neutral port. */ +export type TestRequestHandler = ( + request: Request, + env: Env, + backgroundTasks: BackgroundTasks +) => Promise; + +function adaptForTests(handler: ControlPlaneHttpHandler): TestRequestHandler { + return (request, env, backgroundTasks) => + handler(request, env, executionContextFromBackgroundTasks(backgroundTasks)); +} + +/** Test-only adapter over the production catalog. */ +export const handleRequest: TestRequestHandler = adaptForTests(handleControlPlaneHttp); + +/** + * Test-only adapter over an explicit catalog. Hono registers routes when the + * app is built, so fixtures that need synthetic routes construct their own + * handler instead of mutating the production catalog. + */ +export function createTestRequestHandler(catalog: readonly Route[]): TestRequestHandler { + return adaptForTests(createControlPlaneHttpHandler(catalog)); +} + /** Per-service secrets for unit-test env fixtures, mirrored by signedServiceRequest. */ export const TEST_SERVICE_SECRETS = { SERVICE_AUTH_SECRET_WEB: "test-service-secret-web", diff --git a/packages/control-plane/src/router.ts b/packages/control-plane/src/router.ts deleted file mode 100644 index 6184423ac4..0000000000 --- a/packages/control-plane/src/router.ts +++ /dev/null @@ -1,1064 +0,0 @@ -/** - * API router for Open-Inspect Control Plane. - */ - -import type { Env } from "./types"; -import { authenticate, isAuthError } from "./auth/authenticate"; -import type { Principal } from "./auth/principal"; -import { getUserAuth, getUserAuthRuntime } from "./auth/user/runtime"; -import { - resolveScmProviderFromEnv, - SourceControlProviderError, - type SourceControlProviderName, -} from "./source-control"; -import { SessionInternalPaths } from "./session/contracts"; -import { createSessionRuntimeClient } from "./session/runtime-client"; - -import { createRequestMetrics, instrumentD1 } from "./db/instrumented-d1"; -import { UserStore } from "./db/user-store"; -import { AutomationStore } from "./db/automation-store"; -import { AuthorizationError, AuthorizationService } from "./authorization/service"; -import { serviceAllowsPermission } from "./authorization/service-permissions"; -import { - auditRouteAuthorizationDecision, - shouldAuditAllowedDecision, - type AuthorizationDecisionRequirement, - type RouteAuthorizationDecision, -} from "./authorization/request-audit"; -import { - SCOPED_PERMISSION_PAIRS, - resolveScopedPermission, - type PermissionId, -} from "@open-inspect/shared/rbac"; -import { createLogger } from "./logger"; -import type { BackgroundTasks } from "./platform-ports"; -import { - type ActorlessServiceGrant, - type Route, - type RouteAuthentication, - type RouteAuthorizationRequirement, - type RequestContext, - defineRoute, - GITHUB_SANDBOX_FALLBACK_ROUTE, - NO_AUTHORIZATION, - parsePattern, - requirePermission, - json, - error, - HttpError, -} from "./routes/shared"; -import { browserAuthRoutes } from "./routes/browser-auth"; -import { signInProviderRoutes } from "./routes/sign-in-providers"; -import { integrationSettingsRoutes } from "./routes/integration-settings"; -import { commitSigningRoutes } from "./routes/commit-signing"; -import { scmSettingsRoutes } from "./routes/scm-settings"; -import { modelPreferencesRoutes } from "./routes/model-preferences"; -import { reposRoutes } from "./routes/repos"; -import { secretsRoutes } from "./routes/secrets"; -import { environmentRoutes } from "./routes/environments"; -import { environmentSecretsRoutes } from "./routes/environment-secrets"; -import { imageBuildRoutes } from "./routes/image-builds"; -import { automationRoutes } from "./routes/automations"; -import { mcpServerRoutes } from "./routes/mcp-servers"; -import { analyticsRoutes } from "./routes/analytics"; -import { auditEventRoutes } from "./routes/audit-events"; -import { autofixRoutes } from "./routes/autofix"; -import { skillRoutes } from "./routes/skills"; -import { keyboardShortcutRoutes } from "./routes/keyboard-shortcuts"; -import { rbacRoutes } from "./routes/rbac"; -import { sessionRoutes } from "./routes/sessions"; -import { modelProviderAccountRoutes } from "./routes/model-provider-accounts"; -import { handleSlackNotify } from "./routes/slack-notify"; -import { webhookRoutes } from "./webhooks"; - -const logger = createLogger("router"); - -function withCorsAndTraceHeaders(response: Response, ctx: RequestContext): Response { - const headers = new Headers(response.headers); - headers.set("Access-Control-Allow-Origin", "*"); - headers.set("x-request-id", ctx.request_id); - headers.set("x-trace-id", ctx.trace_id); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} - -function withRouteCachePolicy(response: Response, route: Route): Response { - if (!route.cacheControl) return response; - const headers = new Headers(response.headers); - headers.set("Cache-Control", route.cacheControl); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} - -type CachedScmProvider = - | { - envValue: string | undefined; - provider: SourceControlProviderName; - error?: never; - } - | { - envValue: string | undefined; - provider?: never; - error: SourceControlProviderError; - }; - -let cachedScmProvider: CachedScmProvider | null = null; - -function resolveDeploymentScmProvider(env: Env): SourceControlProviderName { - const envValue = env.SCM_PROVIDER; - if (!cachedScmProvider || cachedScmProvider.envValue !== envValue) { - try { - cachedScmProvider = { - envValue, - provider: resolveScmProviderFromEnv(envValue), - }; - } catch (errorValue) { - cachedScmProvider = { - envValue, - error: - errorValue instanceof SourceControlProviderError - ? errorValue - : new SourceControlProviderError("Invalid SCM provider configuration", "permanent"), - }; - } - } - - if (cachedScmProvider.error) { - throw cachedScmProvider.error; - } - - return cachedScmProvider.provider; -} - -function enforceImplementedScmProvider( - route: Route, - path: string, - env: Env, - ctx: RequestContext -): Response | null { - try { - const provider = resolveDeploymentScmProvider(env); - if (route.supportedScmProviders !== "all" && !route.supportedScmProviders.includes(provider)) { - logger.warn("SCM provider not implemented", { - event: "scm.provider_not_implemented", - scm_provider: provider, - http_path: path, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - const response = error( - `SCM provider '${provider}' is not implemented in this deployment.`, - 501 - ); - return withCorsAndTraceHeaders(response, ctx); - } - - return null; - } catch (errorValue) { - const errorMessage = - errorValue instanceof SourceControlProviderError - ? errorValue.message - : "Invalid SCM provider configuration"; - - logger.error("Invalid SCM provider configuration", { - event: "scm.provider_invalid", - error: errorValue instanceof Error ? errorValue : String(errorValue), - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - - const response = error(errorMessage, 500); - return withCorsAndTraceHeaders(response, ctx); - } -} - -/** - * Validate sandbox authentication by checking with the Durable Object. - * The DO stores the expected sandbox auth token. - * - * On success, sets the sandbox principal on the request context — this is - * the single place a sandbox principal is assembled. - * - * @param request - The incoming request - * @param env - Environment bindings - * @param sessionId - Session ID extracted from path - * @param ctx - Request correlation context - * @returns null if authentication passes, or an error Response to return immediately - */ -async function verifySandboxAuth( - request: Request, - env: Env, - sessionId: string, - ctx: RequestContext -): Promise { - const authHeader = request.headers.get("Authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return error("Unauthorized: Missing sandbox token", 401); - } - - const token = authHeader.slice(7); // Remove "Bearer " prefix - - // Ask the Session runtime to validate this sandbox token. - const verifyResponse = await createSessionRuntimeClient(env, ctx).fetch( - sessionId, - SessionInternalPaths.verifySandboxToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token }), - } - ); - - if (!verifyResponse.ok) { - const clientIP = request.headers.get("CF-Connecting-IP") || "unknown"; - logger.warn("Auth failed: sandbox", { - event: "auth.sandbox_failed", - http_path: new URL(request.url).pathname, - client_ip: clientIP, - session_id: sessionId, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return error("Unauthorized: Invalid sandbox token", 401); - } - - ctx.principal = { kind: "sandbox", sessionId }; - return null; // Auth passed -} - -async function verifySandboxAuthSafely( - request: Request, - env: Env, - sessionId: string, - ctx: RequestContext -): Promise { - try { - return await verifySandboxAuth(request, env, sessionId, ctx); - } catch (cause) { - logger.error("Sandbox authentication unavailable", { - event: "auth.sandbox_unavailable", - session_id: sessionId, - error: cause instanceof Error ? cause : String(cause), - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return error("Sandbox authentication unavailable", 503); - } -} - -/** - * Emit the per-request `auth.principal` line: who is acting, as a verified - * identity — never token material. - */ -function logPrincipal(principal: Principal, ctx: RequestContext, path: string): void { - const fields: Record = { principal_kind: principal.kind }; - switch (principal.kind) { - case "service": - fields.auth_scheme = "per-service"; - fields.service = principal.service; - fields.actor = principal.actor?.participantUserId; - break; - case "sandbox": - fields.session_id = principal.sessionId; - break; - case "user": - fields.user_id = principal.userId; - break; - } - logger.info("auth.principal", { - event: "auth.principal", - ...fields, - http_path: path, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); -} - -function logRequest( - response: Response, - ctx: RequestContext, - method: string, - path: string, - startTime: number -): void { - logger.info("http.request", { - event: "http.request", - request_id: ctx.request_id, - trace_id: ctx.trace_id, - http_method: method, - http_path: path, - http_status: response.status, - duration_ms: Date.now() - startTime, - outcome: response.status >= 500 ? "error" : "success", - ...ctx.metrics.summarize(), - }); -} - -type AllowedAuthorizationDecision = Extract; -type DeniedAuthorizationDecision = Extract; - -interface AuthorizationFailure { - response: Response; - decision?: DeniedAuthorizationDecision; -} - -type RouteAuthorizationResult = - | { kind: "allowed"; decision: AllowedAuthorizationDecision } - | { kind: "denied"; response: Response; decision: DeniedAuthorizationDecision } - | { kind: "error"; response: Response }; - -interface AuthorizationEvidence { - requirements: AuthorizationDecisionRequirement[]; - effectivePermissions: PermissionId[]; -} - -function authorizationDenial( - response: Response, - evidence: AuthorizationEvidence, - failedRequirement: AuthorizationDecisionRequirement, - reasonCode: string, - reason: string, - failedPermission?: PermissionId -): AuthorizationFailure { - return { - response, - decision: { - kind: "denied", - ...evidence, - requirements: [...evidence.requirements, failedRequirement], - reasonCode, - reason, - ...(failedPermission ? { failedPermission } : {}), - }, - }; -} - -function resultForFailure( - failure: AuthorizationFailure -): Exclude { - return failure.decision - ? { kind: "denied", response: failure.response, decision: failure.decision } - : { kind: "error", response: failure.response }; -} - -export function enforceRoutePrincipal( - authentication: RouteAuthentication, - principal: Principal, - evidence: AuthorizationEvidence = { requirements: [], effectivePermissions: [] } -): AuthorizationFailure | null { - if ( - authentication.kind === "web-service" && - (principal.kind !== "service" || principal.service !== "web") - ) { - return { response: error("Unauthorized", 401) }; - } - if (authentication.kind === "user" && principal.kind !== "user") { - return authorizationDenial( - error("Human user authentication required", 403), - evidence, - { kind: "principal-type" }, - "principal_type_required", - "Human user authentication required" - ); - } - if (authentication.kind === "service" && principal.kind !== "service") { - return authorizationDenial( - error("Service authentication required", 403), - evidence, - { kind: "principal-type" }, - "principal_type_required", - "Service authentication required" - ); - } - return null; -} - -async function enforceActiveUser( - route: Route, - ctx: RequestContext, - evidence: AuthorizationEvidence -): Promise { - if ( - route.authorization.kind !== "active-user" && - route.authorization.kind !== "active-self" && - route.authorization.kind !== "active-global" - ) { - return null; - } - let resolvedServiceUserId: string | null = null; - if ( - ctx.principal?.kind === "service" && - ctx.principal.actor && - !ctx.principal.actor.canonicalUserId - ) { - try { - const user = await new UserStore(ctx.db).resolveOrCreateUser({ - provider: ctx.principal.actor.provider, - providerUserId: ctx.principal.actor.providerUserId, - }); - resolvedServiceUserId = user.id; - } catch { - return { - response: json( - { error: "Authorization unavailable", code: "authorization_unavailable" }, - 503 - ), - }; - } - } - const userId = - ctx.principal?.kind === "user" - ? ctx.principal.userId - : ctx.principal?.kind === "service" - ? (ctx.principal.actor?.canonicalUserId ?? resolvedServiceUserId) - : null; - if (!userId) return null; - const requirement = { kind: "active-user" } as const; - try { - const authorization = await new AuthorizationService(ctx.db).getEffectiveAuthorization(userId); - ctx.authorization = authorization; - if (authorization.suspendedAt !== null) { - return authorizationDenial( - json({ error: "Forbidden", code: "active_user_required" }, 403), - evidence, - requirement, - "active_user_required", - "Forbidden" - ); - } - evidence.requirements.push(requirement); - return null; - } catch (cause) { - if (cause instanceof AuthorizationError) { - return authorizationDenial( - json({ error: "Forbidden", code: cause.code }, cause.status), - evidence, - requirement, - cause.code, - "Forbidden", - cause.permission - ); - } - return { - response: json( - { error: "Authorization unavailable", code: "authorization_unavailable" }, - 503 - ), - }; - } -} - -function authorizationUserId(ctx: RequestContext): string | null { - if (ctx.principal?.kind === "user") return ctx.principal.userId; - if (ctx.principal?.kind === "service") { - return ctx.principal.actor?.canonicalUserId ?? ctx.authorization?.userId ?? null; - } - return null; -} - -function actorlessGrantMatches( - grant: ActorlessServiceGrant, - service: string, - match: RegExpMatchArray -): boolean { - if (grant.service !== service) return false; - return Object.entries(grant.pathParams ?? {}).every(([name, expected]) => { - const value = match.groups?.[name]; - if (value === undefined) return false; - try { - return decodeURIComponent(value) === expected; - } catch { - return false; - } - }); -} - -function enforceServiceRouteAuthorization( - route: Route, - match: RegExpMatchArray, - ctx: RequestContext, - evidence: AuthorizationEvidence -): AuthorizationFailure | null { - const principal = ctx.principal; - const authorization = route.authorization; - const requirement = { kind: "service-capability" } as const; - if (authorization.kind === "service") { - if (principal?.kind !== "service") { - return authorizationDenial( - json({ error: "Forbidden", code: "service_capability_required" }, 403), - evidence, - requirement, - "service_capability_required", - "Forbidden" - ); - } - if (!authorization.services.some((service) => service === principal.service)) { - return authorizationDenial( - json({ error: "Forbidden", code: "service_capability_required" }, 403), - evidence, - requirement, - "service_capability_required", - "Forbidden" - ); - } - if (authorization.actor === "required" && !principal.actor) { - return authorizationDenial( - json({ error: "Forbidden", code: "service_actor_required" }, 403), - evidence, - requirement, - "service_actor_required", - "Forbidden" - ); - } - evidence.requirements.push(requirement); - return null; - } - if (principal?.kind !== "service") return null; - if (route.authentication.kind === "web-service" && principal.service === "web") return null; - if ( - (authorization.kind !== "active-user" && authorization.kind !== "active-global") || - authorization.service.kind === "deny" - ) { - return authorizationDenial( - json({ error: "Forbidden", code: "service_capability_required" }, 403), - evidence, - requirement, - "service_capability_required", - "Forbidden" - ); - } - if (principal.actor) { - evidence.requirements.push(requirement); - return null; - } - const granted = authorization.service.actorlessGrants?.some((grant) => - actorlessGrantMatches(grant, principal.service, match) - ); - if (granted) { - evidence.requirements.push({ kind: "actorless-service-grant", service: principal.service }); - return null; - } - return authorizationDenial( - json({ error: "Forbidden", code: "service_actor_required" }, 403), - evidence, - requirement, - "service_actor_required", - "Forbidden" - ); -} - -async function enforcePermissionRequirement( - requirement: Extract, - ctx: RequestContext, - evidence: AuthorizationEvidence -): Promise { - if ( - ctx.principal?.kind === "service" && - !serviceAllowsPermission(ctx.principal.service, requirement.permission) - ) { - return authorizationDenial( - json({ error: "Forbidden", code: "service_capability_required" }, 403), - evidence, - requirement, - "service_capability_required", - "Forbidden", - requirement.permission - ); - } - const userId = authorizationUserId(ctx); - if (!userId) { - evidence.requirements.push(requirement); - return null; - } - if (ctx.authorization?.permissions.includes(requirement.permission)) { - evidence.requirements.push(requirement); - evidence.effectivePermissions.push(requirement.permission); - return null; - } - return authorizationDenial( - json( - { error: "Forbidden", code: "permission_required", permission: requirement.permission }, - 403 - ), - evidence, - requirement, - "permission_required", - "Forbidden", - requirement.permission - ); -} - -async function enforceScopedPermissionRequirement( - requirement: Extract, - ctx: RequestContext, - evidence: AuthorizationEvidence -): Promise { - const pair = SCOPED_PERMISSION_PAIRS[requirement.stem]; - if ( - ctx.principal?.kind === "service" && - !serviceAllowsPermission(ctx.principal.service, pair.own) - ) { - return authorizationDenial( - json({ error: "Forbidden", code: "service_capability_required" }, 403), - evidence, - requirement, - "service_capability_required", - "Forbidden", - pair.own - ); - } - const userId = authorizationUserId(ctx); - if (!userId) { - evidence.requirements.push(requirement); - return null; - } - const scope = ctx.authorization - ? resolveScopedPermission(requirement.stem, ctx.authorization.permissions) - : null; - if (scope) { - evidence.requirements.push(requirement); - evidence.effectivePermissions.push(pair[scope]); - return null; - } - return authorizationDenial( - json({ error: "Forbidden", code: "permission_required", permission: pair.own }, 403), - evidence, - requirement, - "permission_required", - "Forbidden", - pair.own - ); -} - -async function enforceAutomationRequirement( - requirement: Extract, - match: RegExpMatchArray, - ctx: RequestContext, - evidence: AuthorizationEvidence -): Promise { - if (ctx.principal?.kind !== "user") return null; - const encodedAutomationId = match.groups?.[requirement.automationIdParam]; - if (!encodedAutomationId) return { response: json({ error: "Invalid automation route" }, 400) }; - let automationId: string; - try { - automationId = decodeURIComponent(encodedAutomationId); - } catch { - return { response: json({ error: "Invalid automation route" }, 400) }; - } - - try { - const authorization = ctx.authorization; - if (!authorization) throw new Error("Missing request authorization"); - const store = new AutomationStore(ctx.db); - const storedAutomation = await store.getById(automationId); - if (!storedAutomation) return { response: error("Automation not found", 404) }; - const automation = await store.resolveCanonicalOwner(storedAutomation); - - const permissionStem = `automations.${requirement.operation}` as const; - const pair = SCOPED_PERMISSION_PAIRS[permissionStem]; - const isOwner = automation.user_id === ctx.principal.userId; - const scope = resolveScopedPermission(permissionStem, authorization.permissions); - if (!scope || (scope === "own" && !isOwner)) { - return authorizationDenial( - json( - { - error: "Forbidden", - code: "permission_required", - permission: pair.own, - }, - 403 - ), - evidence, - requirement, - "permission_required", - "Forbidden", - pair.own - ); - } - - evidence.requirements.push(requirement); - evidence.effectivePermissions.push(pair[scope]); - ctx.automationAdmission = { automation }; - return null; - } catch { - return { - response: json( - { error: "Authorization unavailable", code: "authorization_unavailable" }, - 503 - ), - }; - } -} - -async function enforceRouteAuthorization( - route: Route, - match: RegExpMatchArray, - ctx: RequestContext -): Promise { - const evidence: AuthorizationEvidence = { requirements: [], effectivePermissions: [] }; - const principal = ctx.principal; - if (!principal) { - return { - kind: "allowed", - decision: { - kind: "allowed", - admission: "user", - auditAllowed: route.authorization.auditAllowed, - ...evidence, - }, - }; - } - - const principalFailure = enforceRoutePrincipal(route.authentication, principal, evidence); - if (principalFailure) return resultForFailure(principalFailure); - - if ( - principal.kind === "sandbox" && - route.authentication.kind === "user-or-service-with-sandbox-fallback" - ) { - evidence.requirements.push({ kind: "sandbox-admission", sessionId: principal.sessionId }); - return { - kind: "allowed", - decision: { - kind: "allowed", - admission: "sandbox", - auditAllowed: route.authorization.auditAllowed, - ...evidence, - }, - }; - } - - const serviceFailure = enforceServiceRouteAuthorization(route, match, ctx, evidence); - if (serviceFailure) return resultForFailure(serviceFailure); - - const activeUserFailure = await enforceActiveUser(route, ctx, evidence); - if (activeUserFailure) return resultForFailure(activeUserFailure); - - if (route.authorization.kind === "active-user") { - for (const requirement of route.authorization.allOf) { - let failure: AuthorizationFailure | null; - switch (requirement.kind) { - case "permission": - failure = await enforcePermissionRequirement(requirement, ctx, evidence); - break; - case "scoped-permission": - failure = await enforceScopedPermissionRequirement(requirement, ctx, evidence); - break; - case "automation": - failure = await enforceAutomationRequirement(requirement, match, ctx, evidence); - break; - } - if (failure) return resultForFailure(failure); - } - } - return { - kind: "allowed", - decision: { - kind: "allowed", - admission: - principal.kind === "service" - ? "service" - : principal.kind === "sandbox" - ? "sandbox" - : "user", - auditAllowed: route.authorization.auditAllowed, - ...evidence, - }, - }; -} - -/** - * Routes definition. - */ -export const routes: Route[] = [ - // Health check - { - authentication: { kind: "public" }, - supportedScmProviders: "all", - method: "GET", - pattern: parsePattern("/health"), - authorization: NO_AUTHORIZATION, - handler: async () => - json({ - status: "healthy", - service: "open-inspect-control-plane", - }), - }, - - ...browserAuthRoutes, - ...signInProviderRoutes, - - // Session management - ...sessionRoutes, - // Agent-initiated Slack notification (sandbox-authenticated) - defineRoute(GITHUB_SANDBOX_FALLBACK_ROUTE, { - method: "POST", - pattern: parsePattern("/sessions/:id/slack-notify"), - authorization: requirePermission("sessions.collaborate"), - handler: handleSlackNotify, - }), - - // Repository management - ...reposRoutes, - - // Secrets - ...secretsRoutes, - - // Environments (Phase-2 session target; internal-HMAC only, web BFF proxied) - ...environmentRoutes, - ...environmentSecretsRoutes, - - // Image builds (scope-generic) - ...imageBuildRoutes, - - // Model preferences - ...modelPreferencesRoutes, - - // Subscription provider account management and sandbox access broker - ...modelProviderAccountRoutes, - - // Integration settings - ...integrationSettingsRoutes, - - // Deployment-wide commit signing identity - ...commitSigningRoutes, - - // SCM (source-control) settings - ...scmSettingsRoutes, - - // Automations - ...automationRoutes, - - // MCP servers - ...mcpServerRoutes, - - // Analytics - ...analyticsRoutes, - - // Workspace audit log - ...auditEventRoutes, - - // Pull request feedback Autofix activity - ...autofixRoutes, - - // Installation-wide managed skills and personal profiles - ...skillRoutes, - - // Personal keyboard shortcuts - ...keyboardShortcutRoutes, - - // Workspace roles, members, and current-user authorization - ...rbacRoutes, - - // Webhooks (public routes — auth handled per-route) - ...webhookRoutes, -]; - -/** - * Match request to route and execute handler. - */ -export async function handleRequest( - request: Request, - env: Env, - executionCtx: BackgroundTasks -): Promise { - const url = new URL(request.url); - const path = url.pathname; - const method = request.method; - const startTime = Date.now(); - - // The DB binding is required (types.ts) and the control plane cannot serve - // requests without it. Reject a missing binding once here — the single - // honest boundary — so ctx.db is genuinely always present in handlers and - // no per-route degraded-mode guards are needed. - // eslint-disable-next-line no-restricted-syntax -- composition root: the one route-layer env.DB read - if (!env.DB) { - logger.error("DB binding is not configured; refusing request", { http_path: path }); - return new Response(JSON.stringify({ error: "Database not configured" }), { - status: 503, - headers: { "Content-Type": "application/json" }, - }); - } - - // Build correlation context with per-request metrics and the instrumented - // database handle. Handlers use ctx.db (never env.DB) so all queries are - // automatically timed. - const metrics = createRequestMetrics(); - const ctx: RequestContext = { - trace_id: request.headers.get("x-trace-id") || crypto.randomUUID(), - request_id: crypto.randomUUID().slice(0, 8), - metrics, - // eslint-disable-next-line no-restricted-syntax -- composition root: the one route-layer env.DB read - db: instrumentD1(env.DB, metrics), - // env.DB (not the per-request instrumented wrapper) keys the memoized - // Better Auth runtime: the canonical adapter accepts any SqlDatabase, but - // cache identity requires the stable object. - // eslint-disable-next-line no-restricted-syntax -- composition root: stable cache key for the auth runtime - getUserAuth: () => getUserAuth(env, env.DB), - // eslint-disable-next-line no-restricted-syntax -- composition root owns normalized auth runtime construction - getUserAuthRuntime: () => getUserAuthRuntime(env, env.DB), - executionCtx, - }; - - // CORS preflight - if (method === "OPTIONS") { - return new Response(null, { - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Max-Age": "86400", - "x-request-id": ctx.request_id, - "x-trace-id": ctx.trace_id, - }, - }); - } - - const matchedRoute = routes - .filter((route) => route.method === method) - .map((route) => ({ route, match: path.match(route.pattern) })) - .find( - (candidate): candidate is { route: Route; match: RegExpMatchArray } => - candidate.match !== null - ); - if (!matchedRoute) { - return withCorsAndTraceHeaders(error("Not found", 404), ctx); - } - - const authentication = matchedRoute.route.authentication; - if (authentication.kind !== "public" && authentication.kind !== "handler-authenticated") { - let authError: Response | null; - - const sandboxSessionId = - authentication.kind === "sandbox" || - authentication.kind === "user-or-service-with-sandbox-fallback" - ? authentication.getSessionId(matchedRoute.match) - : null; - - if (authentication.kind === "sandbox") { - authError = sandboxSessionId - ? await verifySandboxAuthSafely(request, env, sandboxSessionId, ctx) - : error("Unauthorized: Invalid session path", 401); - } else { - const authResult = await authenticate(request, env, ctx, { - webService: - authentication.kind === "web-service" || authentication.kind === "service" - ? "service" - : "user", - }); - - if (isAuthError(authResult)) { - // A service-credential attempt is terminal; only a request with no - // recognized credential may still be a sandbox-token call on a - // sandbox-accepting route. - authError = error(authResult.reason, authResult.status); - - if ( - authResult.failedScheme === "none" && - authentication.kind === "user-or-service-with-sandbox-fallback" && - sandboxSessionId - ) { - authError = await verifySandboxAuthSafely(request, env, sandboxSessionId, ctx); - } - } else { - authError = null; - ctx.principal = authResult.principal; - ctx.authentication = authResult.authentication; - request = authResult.request; - } - } - - if (authError) { - if (ctx.principal) { - logPrincipal(ctx.principal, ctx, path); - logRequest(authError, ctx, method, path, startTime); - } - return withCorsAndTraceHeaders(withRouteCachePolicy(authError, matchedRoute.route), ctx); - } - - if (ctx.principal) { - logPrincipal(ctx.principal, ctx, path); - } - } - - const authorizationResult = await enforceRouteAuthorization( - matchedRoute.route, - matchedRoute.match, - ctx - ); - if (authorizationResult.kind !== "allowed") { - if (authorizationResult.kind === "denied") { - await auditRouteAuthorizationDecision({ - ctx, - method, - path, - response: authorizationResult.response, - decision: authorizationResult.decision, - }); - } - logRequest(authorizationResult.response, ctx, method, path, startTime); - return withCorsAndTraceHeaders( - withRouteCachePolicy(authorizationResult.response, matchedRoute.route), - ctx - ); - } - - const providerCheck = enforceImplementedScmProvider(matchedRoute.route, path, env, ctx); - if (providerCheck) { - return withRouteCachePolicy(providerCheck, matchedRoute.route); - } - - let response: Response; - try { - response = await matchedRoute.route.handler(request, env, matchedRoute.match, ctx); - } catch (e) { - if (e instanceof HttpError) { - response = error(e.message, e.status); - } else { - const durationMs = Date.now() - startTime; - logger.error("http.request", { - event: "http.request", - request_id: ctx.request_id, - trace_id: ctx.trace_id, - http_method: method, - http_path: path, - http_status: 500, - duration_ms: durationMs, - outcome: "error", - error: e instanceof Error ? e : String(e), - ...ctx.metrics.summarize(), - }); - response = error("Internal server error", 500); - if (shouldAuditAllowedDecision(authorizationResult.decision)) { - await auditRouteAuthorizationDecision({ - ctx, - method, - path, - response, - decision: authorizationResult.decision, - }); - } - return withCorsAndTraceHeaders(withRouteCachePolicy(response, matchedRoute.route), ctx); - } - } - - logRequest(response, ctx, method, path, startTime); - - if (shouldAuditAllowedDecision(authorizationResult.decision)) { - await auditRouteAuthorizationDecision({ - ctx, - method, - path, - response, - decision: authorizationResult.decision, - }); - } - - return withCorsAndTraceHeaders(withRouteCachePolicy(response, matchedRoute.route), ctx); -} diff --git a/packages/control-plane/src/routes/analytics.ts b/packages/control-plane/src/routes/analytics.ts index 51422a262f..36813317a4 100644 --- a/packages/control-plane/src/routes/analytics.ts +++ b/packages/control-plane/src/routes/analytics.ts @@ -18,7 +18,6 @@ import { defineRoutes, error, json, - parsePattern, requirePermission, } from "./shared"; @@ -148,31 +147,31 @@ async function handlePullRequests( export const analyticsRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, [ { method: "GET", - pattern: parsePattern("/analytics/dashboard"), + path: "/analytics/dashboard", authorization: requirePermission("analytics.read"), handler: handleDashboard, }, { method: "GET", - pattern: parsePattern("/analytics/summary"), + path: "/analytics/summary", authorization: requirePermission("analytics.read"), handler: handleSummary, }, { method: "GET", - pattern: parsePattern("/analytics/timeseries"), + path: "/analytics/timeseries", authorization: requirePermission("analytics.read"), handler: handleTimeseries, }, { method: "GET", - pattern: parsePattern("/analytics/breakdown"), + path: "/analytics/breakdown", authorization: requirePermission("analytics.read"), handler: handleBreakdown, }, { method: "GET", - pattern: parsePattern("/analytics/pull-requests"), + path: "/analytics/pull-requests", authorization: requirePermission("analytics.read"), handler: handlePullRequests, }, diff --git a/packages/control-plane/src/routes/audit-events.ts b/packages/control-plane/src/routes/audit-events.ts index 0025166173..2bc5b00875 100644 --- a/packages/control-plane/src/routes/audit-events.ts +++ b/packages/control-plane/src/routes/audit-events.ts @@ -6,7 +6,6 @@ import { defineRoutes, error, json, - parsePattern, requirePermission, type RequestContext, type Route, @@ -52,7 +51,7 @@ async function handleListAuditEvents( export const auditEventRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [ { method: "GET", - pattern: parsePattern("/audit-events"), + path: "/audit-events", authorization: requirePermission("workspace.audit.read", { service: "deny" }), cacheControl: "private, no-store", handler: handleListAuditEvents, diff --git a/packages/control-plane/src/routes/autofix.ts b/packages/control-plane/src/routes/autofix.ts index f132d243d1..6f6793517b 100644 --- a/packages/control-plane/src/routes/autofix.ts +++ b/packages/control-plane/src/routes/autofix.ts @@ -4,7 +4,6 @@ import { error, json, NO_AUTHORIZATION, - parsePattern, SCM_AGNOSTIC_WEB_SERVICE_ROUTE, type Route, } from "./shared"; @@ -35,7 +34,7 @@ const handleActivity: Route["handler"] = async (request, _env, _match, ctx) => { export const autofixRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_WEB_SERVICE_ROUTE, [ { method: "GET", - pattern: parsePattern("/autofix/activity"), + path: "/autofix/activity", authorization: NO_AUTHORIZATION, handler: handleActivity, }, diff --git a/packages/control-plane/src/routes/automations.test.ts b/packages/control-plane/src/routes/automations.test.ts index 1a7adff021..783fec674c 100644 --- a/packages/control-plane/src/routes/automations.test.ts +++ b/packages/control-plane/src/routes/automations.test.ts @@ -931,7 +931,7 @@ describe("automation route handlers", () => { ); }); - it("resolves an unseen bot actor via the user store with body display fields", async () => { + it("fails closed when a bot actor bypasses admission", async () => { mockStore.getById.mockResolvedValue(sampleRow); const res = await callRoute("POST", "/automations", { @@ -944,17 +944,10 @@ describe("automation route handlers", () => { principal: SLACK_BOT_PRINCIPAL, }); - expect(res.status).toBe(201); - expect(mockUserStore.resolveOrCreateUser).toHaveBeenCalledWith({ - provider: "slack", - providerUserId: "U0123", - displayName: "Alice", - providerEmail: "alice@corp.com", - avatarUrl: "https://avatars.test/alice.png", - }); - expect(mockStore.bindAutomationInsert).toHaveBeenCalledWith( - expect.objectContaining({ created_by: "slack:U0123", user_id: "resolved-user-1" }) - ); + expect(res.status).toBe(500); + await expect(res.json()).resolves.toEqual({ error: "Failed to resolve session identity" }); + expect(mockUserStore.resolveOrCreateUser).not.toHaveBeenCalled(); + expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); }); it("rejects forbidden body identity fields", async () => { diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index 5bfd9b0f0f..c24ae0939b 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -46,7 +46,10 @@ import { parseAndValidateAutomationProviderSelections, } from "../model-provider-accounts/automation-provider-selection"; import { generateId } from "../auth/crypto"; -import { applyIdentityEnforcement, resolveCanonicalUserId } from "../auth/identity-enforcement"; +import { + applyIdentityEnforcement, + requireAdmittedCanonicalUserId, +} from "../routing/identity-enforcement"; import { generateWebhookApiKey, hashApiKey, encryptSentrySecret } from "../auth/webhook-key"; import { createLogger } from "../logger"; import { @@ -61,7 +64,6 @@ import { type RequestContext, GITHUB_USER_OR_SERVICE_ROUTE, defineRoutes, - parsePattern, json, error, parseJsonBody, @@ -700,16 +702,11 @@ async function handleCreateAutomation( triggerAuthData = await encryptSentrySecret(sentrySecret, env.REPO_SECRETS_ENCRYPTION_KEY); } - // Resolve the canonical user model ID fail-closed from the verified - // principal — the scheduler replays user_id as session identity at fire - // time, so an automation must never be created with lost attribution. - const resolution = await resolveCanonicalUserId(new UserStore(ctx.db), ctx, enforced, { - displayName: body.actorDisplayName, - email: body.actorEmail, - avatarUrl: body.actorAvatarUrl, - }); + // The scheduler replays user_id as session identity at fire time, so the + // handler may consume only the canonical subject admitted before RBAC. + const resolution = requireAdmittedCanonicalUserId(ctx, enforced); if (resolution instanceof Response) return resolution; - const resolvedUserId = resolution.userId; + const resolvedUserId = resolution; const db: SqlDatabase = ctx.db; const store = new AutomationStore(db); @@ -1449,7 +1446,7 @@ async function handleGetSlackChannels( export const automationRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ { method: "GET", - pattern: parsePattern("/integration-settings/slack/watched-channels"), + path: "/integration-settings/slack/watched-channels", authorization: requirePermission("automations.read", { actorlessGrants: [{ service: "slack-bot" }], }), @@ -1457,73 +1454,73 @@ export const automationRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROU }, { method: "GET", - pattern: parsePattern("/integration-settings/slack/channels"), + path: "/integration-settings/slack/channels", authorization: requirePermission("automations.read"), handler: handleGetSlackChannels, }, { method: "GET", - pattern: parsePattern("/automations"), + path: "/automations", authorization: requirePermission("automations.read"), handler: handleListAutomations, }, { method: "POST", - pattern: parsePattern("/automations"), + path: "/automations", authorization: requirePermission("automations.create"), handler: handleCreateAutomation, }, { method: "GET", - pattern: parsePattern("/automations/:id"), + path: "/automations/:id", authorization: requirePermission("automations.read"), handler: handleGetAutomation, }, { method: "PUT", - pattern: parsePattern("/automations/:id"), + path: "/automations/:id", authorization: requireAutomation("manage"), handler: handleUpdateAutomation, }, { method: "DELETE", - pattern: parsePattern("/automations/:id"), + path: "/automations/:id", authorization: requireAutomation("manage"), handler: handleDeleteAutomation, }, { method: "POST", - pattern: parsePattern("/automations/:id/pause"), + path: "/automations/:id/pause", authorization: requireAutomation("manage"), handler: handlePauseAutomation, }, { method: "POST", - pattern: parsePattern("/automations/:id/resume"), + path: "/automations/:id/resume", authorization: requireAutomation("manage"), handler: handleResumeAutomation, }, { method: "POST", - pattern: parsePattern("/automations/:id/trigger"), + path: "/automations/:id/trigger", authorization: requireAutomation("trigger"), handler: handleTriggerAutomation, }, { method: "GET", - pattern: parsePattern("/automations/:id/invocations"), + path: "/automations/:id/invocations", authorization: requirePermission("automations.read"), handler: handleListInvocations, }, { method: "GET", - pattern: parsePattern("/automations/:id/runs/:runId"), + path: "/automations/:id/runs/:runId", authorization: requirePermission("automations.read"), handler: handleGetRun, }, { method: "POST", - pattern: parsePattern("/automations/:id/regenerate-key"), + path: "/automations/:id/regenerate-key", authorization: requireAutomation("manage"), handler: handleRegenerateKey, }, diff --git a/packages/control-plane/src/routes/browser-auth.ts b/packages/control-plane/src/routes/browser-auth.ts index 395fe0eb88..8e275a584b 100644 --- a/packages/control-plane/src/routes/browser-auth.ts +++ b/packages/control-plane/src/routes/browser-auth.ts @@ -5,7 +5,6 @@ import { defineRoutes, error, NO_AUTHORIZATION, - parsePattern, SCM_AGNOSTIC_WEB_SERVICE_ROUTE, type Route, } from "./shared"; @@ -87,7 +86,7 @@ export const browserAuthRoutes: Route[] = defineRoutes( SCM_AGNOSTIC_WEB_SERVICE_ROUTE, BROWSER_AUTH_PROXY_ROUTES.map(([method, path]) => ({ method, - pattern: parsePattern(path), + path: path, authorization: NO_AUTHORIZATION, handler: handleBrowserAuth, })) diff --git a/packages/control-plane/src/routes/catalog.ts b/packages/control-plane/src/routes/catalog.ts new file mode 100644 index 0000000000..f651c7bba7 --- /dev/null +++ b/packages/control-plane/src/routes/catalog.ts @@ -0,0 +1,123 @@ +/** + * Canonical control-plane HTTP route catalog. + * + * Registration order is part of the routing contract for overlapping static + * and parameterized paths. + */ + +import { webhookRoutes } from "../webhooks"; +import { analyticsRoutes } from "./analytics"; +import { auditEventRoutes } from "./audit-events"; +import { autofixRoutes } from "./autofix"; +import { automationRoutes } from "./automations"; +import { browserAuthRoutes } from "./browser-auth"; +import { commitSigningRoutes } from "./commit-signing"; +import { environmentSecretsRoutes } from "./environment-secrets"; +import { environmentRoutes } from "./environments"; +import { imageBuildRoutes } from "./image-builds"; +import { integrationSettingsRoutes } from "./integration-settings"; +import { keyboardShortcutRoutes } from "./keyboard-shortcuts"; +import { mcpServerRoutes } from "./mcp-servers"; +import { modelPreferencesRoutes } from "./model-preferences"; +import { modelProviderAccountRoutes } from "./model-provider-accounts"; +import { rbacRoutes } from "./rbac"; +import { reposRoutes } from "./repos"; +import { scmSettingsRoutes } from "./scm-settings"; +import { secretsRoutes } from "./secrets"; +import { sessionRoutes } from "./sessions"; +import { handleSlackNotify } from "./slack-notify"; +import { signInProviderRoutes } from "./sign-in-providers"; +import { skillRoutes } from "./skills"; +import { + defineRoute, + GITHUB_SANDBOX_FALLBACK_ROUTE, + json, + NO_AUTHORIZATION, + requirePermission, + type Route, +} from "./shared"; + +export const routes: Route[] = [ + // Health check + defineRoute( + { authentication: { kind: "public" }, supportedScmProviders: "all" }, + { + method: "GET", + path: "/health", + authorization: NO_AUTHORIZATION, + handler: async () => + json({ + status: "healthy", + service: "open-inspect-control-plane", + }), + } + ), + + ...browserAuthRoutes, + ...signInProviderRoutes, + + // Session management + ...sessionRoutes, + // Agent-initiated Slack notification (sandbox-authenticated) + defineRoute(GITHUB_SANDBOX_FALLBACK_ROUTE, { + method: "POST", + path: "/sessions/:id/slack-notify", + authorization: requirePermission("sessions.collaborate"), + handler: handleSlackNotify, + }), + + // Repository management + ...reposRoutes, + + // Secrets + ...secretsRoutes, + + // Environments (Phase-2 session target; internal-HMAC only, web BFF proxied) + ...environmentRoutes, + ...environmentSecretsRoutes, + + // Image builds (scope-generic) + ...imageBuildRoutes, + + // Model preferences + ...modelPreferencesRoutes, + + // Subscription provider account management and sandbox access broker + ...modelProviderAccountRoutes, + + // Integration settings + ...integrationSettingsRoutes, + + // Deployment-wide commit signing identity + ...commitSigningRoutes, + + // SCM (source-control) settings + ...scmSettingsRoutes, + + // Automations + ...automationRoutes, + + // MCP servers + ...mcpServerRoutes, + + // Analytics + ...analyticsRoutes, + + // Workspace audit log + ...auditEventRoutes, + + // Pull request feedback Autofix activity + ...autofixRoutes, + + // Installation-wide managed skills and personal profiles + ...skillRoutes, + + // Personal keyboard shortcuts + ...keyboardShortcutRoutes, + + // Workspace roles, members, and current-user authorization + ...rbacRoutes, + + // Webhooks (public routes — auth handled per-route) + ...webhookRoutes, +]; diff --git a/packages/control-plane/src/routes/commit-signing.ts b/packages/control-plane/src/routes/commit-signing.ts index 9286faa8ab..467d244f86 100644 --- a/packages/control-plane/src/routes/commit-signing.ts +++ b/packages/control-plane/src/routes/commit-signing.ts @@ -13,7 +13,6 @@ import { error, json, parseJsonBody, - parsePattern, type RequestContext, type Route, defineRoute, @@ -216,31 +215,31 @@ async function handlePostSandboxCommitSigning( export const commitSigningRoutes: Route[] = [ defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, { method: "GET", - pattern: parsePattern("/commit-signing"), + path: "/commit-signing", authorization: requirePermission("integrations.read"), handler: handleGetCommitSigning, }), defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, { method: "PUT", - pattern: parsePattern("/commit-signing"), + path: "/commit-signing", authorization: requirePermission("commit_signing.manage"), handler: handlePutCommitSigning, }), defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, { method: "DELETE", - pattern: parsePattern("/commit-signing"), + path: "/commit-signing", authorization: requirePermission("commit_signing.manage"), handler: handleDeleteCommitSigning, }), defineRoute(SCM_AGNOSTIC_SANDBOX_ROUTE, { method: "GET", - pattern: parsePattern("/sessions/:id/commit-signing"), + path: "/sessions/:id/commit-signing", authorization: NO_AUTHORIZATION, handler: handleGetSandboxCommitSigning, }), defineRoute(SCM_AGNOSTIC_SANDBOX_ROUTE, { method: "POST", - pattern: parsePattern("/sessions/:id/commit-signing"), + path: "/sessions/:id/commit-signing", authorization: NO_AUTHORIZATION, handler: handlePostSandboxCommitSigning, }), diff --git a/packages/control-plane/src/routes/environment-secrets.ts b/packages/control-plane/src/routes/environment-secrets.ts index 460d56505b..dba011ad6a 100644 --- a/packages/control-plane/src/routes/environment-secrets.ts +++ b/packages/control-plane/src/routes/environment-secrets.ts @@ -18,7 +18,6 @@ import { type RequestContext, GITHUB_USER_OR_SERVICE_ROUTE, defineRoutes, - parsePattern, json, error, parseJsonBody, @@ -304,25 +303,25 @@ async function handleImportEnvironmentSecrets( export const environmentSecretsRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ { method: "GET", - pattern: parsePattern("/environments/:id/secrets"), + path: "/environments/:id/secrets", authorization: requirePermission("environments.secrets.manage"), handler: handleListEnvironmentSecrets, }, { method: "PUT", - pattern: parsePattern("/environments/:id/secrets"), + path: "/environments/:id/secrets", authorization: requirePermission("environments.secrets.manage"), handler: handleSetEnvironmentSecrets, }, { method: "POST", - pattern: parsePattern("/environments/:id/secrets/import"), + path: "/environments/:id/secrets/import", authorization: requirePermission("environments.secrets.manage"), handler: handleImportEnvironmentSecrets, }, { method: "DELETE", - pattern: parsePattern("/environments/:id/secrets/:key"), + path: "/environments/:id/secrets/:key", authorization: requirePermission("environments.secrets.manage"), handler: handleDeleteEnvironmentSecret, }, diff --git a/packages/control-plane/src/routes/environments.ts b/packages/control-plane/src/routes/environments.ts index f0e0fd73ea..cdf97cf7e3 100644 --- a/packages/control-plane/src/routes/environments.ts +++ b/packages/control-plane/src/routes/environments.ts @@ -26,7 +26,6 @@ import { GITHUB_USER_OR_SERVICE_ROUTE, defineRoutes, type RequestContext, - parsePattern, json, error, parseJsonBody, @@ -254,7 +253,7 @@ async function handleDeleteEnvironment( export const environmentRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ { method: "GET", - pattern: parsePattern("/environments"), + path: "/environments", authorization: requirePermission("environments.read", { actorlessGrants: [{ service: "slack-bot" }, { service: "linear-bot" }], }), @@ -262,13 +261,13 @@ export const environmentRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_RO }, { method: "POST", - pattern: parsePattern("/environments"), + path: "/environments", authorization: requirePermission("environments.manage"), handler: handleCreateEnvironment, }, { method: "GET", - pattern: parsePattern("/environments/:id"), + path: "/environments/:id", authorization: requirePermission("environments.read", { actorlessGrants: [{ service: "github-bot" }], }), @@ -276,13 +275,13 @@ export const environmentRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_RO }, { method: "PUT", - pattern: parsePattern("/environments/:id"), + path: "/environments/:id", authorization: requirePermission("environments.manage"), handler: handleUpdateEnvironment, }, { method: "DELETE", - pattern: parsePattern("/environments/:id"), + path: "/environments/:id", authorization: requirePermission("environments.manage"), handler: handleDeleteEnvironment, }, diff --git a/packages/control-plane/src/routes/image-builds.ts b/packages/control-plane/src/routes/image-builds.ts index d199bf7a1d..3b168ea3f3 100644 --- a/packages/control-plane/src/routes/image-builds.ts +++ b/packages/control-plane/src/routes/image-builds.ts @@ -48,7 +48,6 @@ import { extractRepoParams, json, parseJsonBody, - parsePattern, NO_AUTHORIZATION, requirePermission, } from "./shared"; @@ -495,49 +494,49 @@ async function handleGetEnabledRepos( export const imageBuildRoutes: Route[] = [ defineRoute(SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE, { method: "POST", - pattern: parsePattern("/image-builds/build-complete"), + path: "/image-builds/build-complete", authorization: NO_AUTHORIZATION, handler: handleBuildComplete, }), defineRoute(SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE, { method: "POST", - pattern: parsePattern("/image-builds/build-failed"), + path: "/image-builds/build-failed", authorization: NO_AUTHORIZATION, handler: handleBuildFailed, }), defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, { method: "POST", - pattern: parsePattern("/image-builds/trigger/environment/:id"), + path: "/image-builds/trigger/environment/:id", authorization: requirePermission("environments.images.manage"), handler: handleTriggerEnvironmentBuild, }), defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, { method: "POST", - pattern: parsePattern("/image-builds/trigger/repo/:owner/:name"), + path: "/image-builds/trigger/repo/:owner/:name", authorization: requirePermission("repositories.images.manage"), handler: handleTriggerRepoBuild, }), defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, { method: "PUT", - pattern: parsePattern("/image-builds/toggle/repo/:owner/:name"), + path: "/image-builds/toggle/repo/:owner/:name", authorization: requirePermission("repositories.images.manage"), handler: handleToggleRepoImageBuilds, }), defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, { method: "GET", - pattern: parsePattern("/image-builds/status"), + path: "/image-builds/status", authorization: requirePermission("image_builds.read"), handler: handleGetStatus, }), defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, { method: "GET", - pattern: parsePattern("/image-builds/enabled"), + path: "/image-builds/enabled", authorization: requirePermission("image_builds.read"), handler: handleGetEnabledUnits, }), defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, { method: "GET", - pattern: parsePattern("/image-builds/enabled-repos"), + path: "/image-builds/enabled-repos", authorization: requirePermission("image_builds.read"), handler: handleGetEnabledRepos, }), diff --git a/packages/control-plane/src/routes/integration-settings.ts b/packages/control-plane/src/routes/integration-settings.ts index bc2e81e89c..cb84dd56dc 100644 --- a/packages/control-plane/src/routes/integration-settings.ts +++ b/packages/control-plane/src/routes/integration-settings.ts @@ -29,7 +29,6 @@ import { type RequestContext, GITHUB_USER_OR_SERVICE_ROUTE, defineRoutes, - parsePattern, json, error, parseJsonBody, @@ -492,7 +491,7 @@ export const integrationSettingsRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SE // Integration settings — global { method: "GET", - pattern: parsePattern("/integration-settings/:id"), + path: "/integration-settings/:id", authorization: requirePermission("integrations.read", { actorlessGrants: [{ service: "slack-bot", pathParams: { id: "slack" } }], }), @@ -500,38 +499,38 @@ export const integrationSettingsRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SE }, { method: "PUT", - pattern: parsePattern("/integration-settings/:id"), + path: "/integration-settings/:id", authorization: requirePermission("integrations.manage"), handler: handleSetIntegrationSettings, }, { method: "DELETE", - pattern: parsePattern("/integration-settings/:id"), + path: "/integration-settings/:id", authorization: requirePermission("integrations.manage"), handler: handleDeleteIntegrationSettings, }, // Integration settings — per-repo { method: "GET", - pattern: parsePattern("/integration-settings/:id/repos"), + path: "/integration-settings/:id/repos", authorization: requirePermission("integrations.read"), handler: handleListRepoSettings, }, { method: "GET", - pattern: parsePattern("/integration-settings/:id/repos/:owner/:name"), + path: "/integration-settings/:id/repos/:owner/:name", authorization: requirePermission("integrations.read"), handler: handleGetRepoSettings, }, { method: "PUT", - pattern: parsePattern("/integration-settings/:id/repos/:owner/:name"), + path: "/integration-settings/:id/repos/:owner/:name", authorization: requirePermission("repositories.settings.manage"), handler: handleSetRepoSettings, }, { method: "DELETE", - pattern: parsePattern("/integration-settings/:id/repos/:owner/:name"), + path: "/integration-settings/:id/repos/:owner/:name", authorization: requirePermission("repositories.settings.manage"), handler: handleDeleteRepoSettings, }, @@ -539,26 +538,26 @@ export const integrationSettingsRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SE // code-server, and VNC only) { method: "GET", - pattern: parsePattern("/integration-settings/:id/environments/:environmentId"), + path: "/integration-settings/:id/environments/:environmentId", authorization: requirePermission("integrations.read"), handler: handleGetEnvironmentSettings, }, { method: "PUT", - pattern: parsePattern("/integration-settings/:id/environments/:environmentId"), + path: "/integration-settings/:id/environments/:environmentId", authorization: requirePermission("environments.settings.manage"), handler: handleSetEnvironmentSettings, }, { method: "DELETE", - pattern: parsePattern("/integration-settings/:id/environments/:environmentId"), + path: "/integration-settings/:id/environments/:environmentId", authorization: requirePermission("environments.settings.manage"), handler: handleDeleteEnvironmentSettings, }, // Resolved config — used by bots at runtime { method: "GET", - pattern: parsePattern("/integration-settings/:id/resolved/:owner/:name"), + path: "/integration-settings/:id/resolved/:owner/:name", authorization: requirePermission("integrations.read", { actorlessGrants: [ { service: "github-bot", pathParams: { id: "github" } }, diff --git a/packages/control-plane/src/routes/keyboard-shortcuts.ts b/packages/control-plane/src/routes/keyboard-shortcuts.ts index 1dbf5856c8..1e0bd10584 100644 --- a/packages/control-plane/src/routes/keyboard-shortcuts.ts +++ b/packages/control-plane/src/routes/keyboard-shortcuts.ts @@ -7,7 +7,6 @@ import { defineRoutes, error, json, - parsePattern, SCM_AGNOSTIC_HUMAN_USER_ROUTE, type Route, type UserRouteContext, @@ -47,13 +46,13 @@ async function updatePreferences( export const keyboardShortcutRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [ { method: "GET", - pattern: parsePattern("/keyboard-shortcuts"), + path: "/keyboard-shortcuts", authorization: ACTIVE_SELF, handler: getPreferences, }, { method: "PUT", - pattern: parsePattern("/keyboard-shortcuts"), + path: "/keyboard-shortcuts", authorization: activeSelf({ auditAllowed: true }), handler: updatePreferences, }, diff --git a/packages/control-plane/src/routes/mcp-servers.ts b/packages/control-plane/src/routes/mcp-servers.ts index 311ac3ab53..6101e17518 100644 --- a/packages/control-plane/src/routes/mcp-servers.ts +++ b/packages/control-plane/src/routes/mcp-servers.ts @@ -15,7 +15,6 @@ import { GITHUB_USER_OR_SERVICE_ROUTE, defineRoutes, type RequestContext, - parsePattern, json, error, parseJsonBody, @@ -167,31 +166,31 @@ async function handleDeleteMcpServer( export const mcpServerRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ { method: "GET", - pattern: parsePattern("/mcp-servers"), + path: "/mcp-servers", authorization: requirePermission("mcp_servers.read"), handler: handleListMcpServers, }, { method: "POST", - pattern: parsePattern("/mcp-servers"), + path: "/mcp-servers", authorization: requirePermission("mcp_servers.manage"), handler: handleCreateMcpServer, }, { method: "GET", - pattern: parsePattern("/mcp-servers/:id"), + path: "/mcp-servers/:id", authorization: requirePermission("mcp_servers.read"), handler: handleGetMcpServer, }, { method: "PUT", - pattern: parsePattern("/mcp-servers/:id"), + path: "/mcp-servers/:id", authorization: requirePermission("mcp_servers.manage"), handler: handleUpdateMcpServer, }, { method: "DELETE", - pattern: parsePattern("/mcp-servers/:id"), + path: "/mcp-servers/:id", authorization: requirePermission("mcp_servers.manage"), handler: handleDeleteMcpServer, }, diff --git a/packages/control-plane/src/routes/model-preferences.ts b/packages/control-plane/src/routes/model-preferences.ts index cfcfe38fa9..713a61c72c 100644 --- a/packages/control-plane/src/routes/model-preferences.ts +++ b/packages/control-plane/src/routes/model-preferences.ts @@ -11,7 +11,6 @@ import { GITHUB_USER_OR_SERVICE_ROUTE, defineRoutes, type RequestContext, - parsePattern, json, error, parseJsonBody, @@ -110,7 +109,7 @@ async function handleSetModelPreferences( export const modelPreferencesRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ { method: "GET", - pattern: parsePattern("/model-preferences"), + path: "/model-preferences", authorization: activeGlobal({ actorlessGrants: [{ service: "slack-bot" }], }), @@ -118,7 +117,7 @@ export const modelPreferencesRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVI }, { method: "PUT", - pattern: parsePattern("/model-preferences"), + path: "/model-preferences", authorization: requirePermission("models.preferences.manage"), handler: handleSetModelPreferences, }, diff --git a/packages/control-plane/src/routes/model-provider-accounts.ts b/packages/control-plane/src/routes/model-provider-accounts.ts index f854c2344e..bc49758610 100644 --- a/packages/control-plane/src/routes/model-provider-accounts.ts +++ b/packages/control-plane/src/routes/model-provider-accounts.ts @@ -49,7 +49,6 @@ import { error, json, parseJsonBody, - parsePattern, SCM_AGNOSTIC_HUMAN_USER_ROUTE, SCM_AGNOSTIC_SANDBOX_ROUTE, type RequestContext, @@ -182,7 +181,7 @@ function managementRoute( ): Route { return defineRoute(SCM_AGNOSTIC_HUMAN_USER_ROUTE, { method, - pattern: parsePattern(path), + path: path, cacheControl: PRIVATE_NO_STORE, authorization: requirePermission( method === "GET" ? "provider_accounts.read" : "provider_accounts.manage" @@ -484,7 +483,7 @@ export const modelProviderAccountRoutes: Route[] = [ ...managementRoutes, defineRoute(SCM_AGNOSTIC_SANDBOX_ROUTE, { method: "POST", - pattern: parsePattern("/sessions/:id/provider-auth/:provider/access-token"), + path: "/sessions/:id/provider-auth/:provider/access-token", cacheControl: NO_STORE, authorization: NO_AUTHORIZATION, handler: handleProviderAccess, diff --git a/packages/control-plane/src/routes/rbac.ts b/packages/control-plane/src/routes/rbac.ts index 78ccb528b0..4e95dd0c65 100644 --- a/packages/control-plane/src/routes/rbac.ts +++ b/packages/control-plane/src/routes/rbac.ts @@ -164,42 +164,42 @@ async function handleReplaceMemberStatus( export const rbacRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [ { method: "GET", - pattern: /^\/me\/authorization$/, + path: "/me/authorization", authorization: AUTHENTICATED_USER, cacheControl: "private, no-store", handler: handleGetCurrentAuthorization, }, { method: "GET", - pattern: /^\/roles$/, + path: "/roles", authorization: requirePermission("workspace.roles.read"), cacheControl: "private, no-store", handler: handleListRoles, }, { method: "GET", - pattern: /^\/roles\/(?[^/]+)$/, + path: "/roles/:id", authorization: requirePermission("workspace.roles.read"), cacheControl: "private, no-store", handler: handleGetRole, }, { method: "GET", - pattern: /^\/members$/, + path: "/members", authorization: requirePermission("workspace.members.read"), cacheControl: "private, no-store", handler: handleListMembers, }, { method: "PUT", - pattern: /^\/members\/(?[^/]+)\/role$/, + path: "/members/:id/role", authorization: requirePermission("workspace.members.manage"), cacheControl: "private, no-store", handler: handleReplaceMemberRole, }, { method: "PUT", - pattern: /^\/members\/(?[^/]+)\/status$/, + path: "/members/:id/status", authorization: requirePermission("workspace.members.manage"), cacheControl: "private, no-store", handler: handleReplaceMemberStatus, diff --git a/packages/control-plane/src/routes/repos.ts b/packages/control-plane/src/routes/repos.ts index b936ad41e2..a4c4a38bba 100644 --- a/packages/control-plane/src/routes/repos.ts +++ b/packages/control-plane/src/routes/repos.ts @@ -19,7 +19,6 @@ import { GITHUB_USER_OR_SERVICE_ROUTE, defineRoutes, type RequestContext, - parsePattern, json, error, extractRepoParams, @@ -329,7 +328,7 @@ async function handleListBranches( export const reposRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ { method: "GET", - pattern: parsePattern("/repos"), + path: "/repos", authorization: requirePermission("repositories.read", { actorlessGrants: [{ service: "slack-bot" }, { service: "linear-bot" }], }), @@ -337,13 +336,13 @@ export const reposRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ }, { method: "PUT", - pattern: parsePattern("/repos/:owner/:name/metadata"), + path: "/repos/:owner/:name/metadata", authorization: requirePermission("repositories.settings.manage"), handler: handleUpdateRepoMetadata, }, { method: "GET", - pattern: parsePattern("/repos/:owner/:name/metadata"), + path: "/repos/:owner/:name/metadata", authorization: requirePermission("repositories.read", { actorlessGrants: [{ service: "github-bot" }], }), @@ -351,7 +350,7 @@ export const reposRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ }, { method: "GET", - pattern: parsePattern("/repos/:owner/:name/branches"), + path: "/repos/:owner/:name/branches", authorization: requirePermission("repositories.read"), handler: handleListBranches, }, diff --git a/packages/control-plane/src/routes/scm-settings.ts b/packages/control-plane/src/routes/scm-settings.ts index df5c3476e2..fe0ff98813 100644 --- a/packages/control-plane/src/routes/scm-settings.ts +++ b/packages/control-plane/src/routes/scm-settings.ts @@ -20,7 +20,6 @@ import { type RequestContext, SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, defineRoutes, - parsePattern, json, error, parseJsonBody, @@ -225,37 +224,37 @@ async function handleDeleteRepoSettings( export const scmSettingsRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, [ { method: "GET", - pattern: parsePattern("/scm-settings"), + path: "/scm-settings", authorization: requirePermission("integrations.read"), handler: handleGetGlobal, }, { method: "PUT", - pattern: parsePattern("/scm-settings"), + path: "/scm-settings", authorization: requirePermission("scm_settings.manage"), handler: handleSetGlobal, }, { method: "DELETE", - pattern: parsePattern("/scm-settings"), + path: "/scm-settings", authorization: requirePermission("scm_settings.manage"), handler: handleDeleteGlobal, }, { method: "GET", - pattern: parsePattern("/scm-settings/repos"), + path: "/scm-settings/repos", authorization: requirePermission("integrations.read"), handler: handleListRepoSettings, }, { method: "PUT", - pattern: parsePattern("/scm-settings/repos/:owner/:name"), + path: "/scm-settings/repos/:owner/:name", authorization: requirePermission("scm_settings.manage"), handler: handleSetRepoSettings, }, { method: "DELETE", - pattern: parsePattern("/scm-settings/repos/:owner/:name"), + path: "/scm-settings/repos/:owner/:name", authorization: requirePermission("scm_settings.manage"), handler: handleDeleteRepoSettings, }, diff --git a/packages/control-plane/src/routes/secrets.ts b/packages/control-plane/src/routes/secrets.ts index b727a9659e..63263ba6b6 100644 --- a/packages/control-plane/src/routes/secrets.ts +++ b/packages/control-plane/src/routes/secrets.ts @@ -12,7 +12,6 @@ import { GITHUB_USER_OR_SERVICE_ROUTE, defineRoutes, type RequestContext, - parsePattern, json, error, parseJsonBody, @@ -380,37 +379,37 @@ async function handleDeleteGlobalSecret( export const secretsRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ { method: "PUT", - pattern: parsePattern("/repos/:owner/:name/secrets"), + path: "/repos/:owner/:name/secrets", authorization: requirePermission("repositories.secrets.manage"), handler: handleSetRepoSecrets, }, { method: "GET", - pattern: parsePattern("/repos/:owner/:name/secrets"), + path: "/repos/:owner/:name/secrets", authorization: requirePermission("repositories.secrets.manage"), handler: handleListRepoSecrets, }, { method: "DELETE", - pattern: parsePattern("/repos/:owner/:name/secrets/:key"), + path: "/repos/:owner/:name/secrets/:key", authorization: requirePermission("repositories.secrets.manage"), handler: handleDeleteRepoSecret, }, { method: "PUT", - pattern: parsePattern("/secrets"), + path: "/secrets", authorization: requirePermission("global_secrets.manage"), handler: handleSetGlobalSecrets, }, { method: "GET", - pattern: parsePattern("/secrets"), + path: "/secrets", authorization: requirePermission("global_secrets.manage"), handler: handleListGlobalSecrets, }, { method: "DELETE", - pattern: parsePattern("/secrets/:key"), + path: "/secrets/:key", authorization: requirePermission("global_secrets.manage"), handler: handleDeleteGlobalSecret, }, diff --git a/packages/control-plane/src/routes/session-attachments.ts b/packages/control-plane/src/routes/session-attachments.ts index 52f1b7e3d1..f0b15ae48f 100644 --- a/packages/control-plane/src/routes/session-attachments.ts +++ b/packages/control-plane/src/routes/session-attachments.ts @@ -8,7 +8,7 @@ * * GET streams the file back. It is HMAC-authenticated for the web app's proxy * route and sandbox-token-authenticated so the bridge can hydrate attachments - * before prompting OpenCode (see SANDBOX_AUTH_ROUTES in router.ts). + * before prompting OpenCode (see the sandbox-fallback policies in routes/shared.ts). * * Every stored object is registered as an attachment record in the session DO, * which enforces per-session quotas and prunes records never referenced by a @@ -50,7 +50,6 @@ import { GITHUB_SANDBOX_FALLBACK_ROUTE, GITHUB_USER_OR_SERVICE_ROUTE, json, - parsePattern, requirePermission, type Route, } from "./shared"; @@ -243,7 +242,7 @@ export const sessionAttachmentRoutes: Route[] = [ GITHUB_USER_OR_SERVICE_ROUTE, sessionRoute({ method: "POST", - pattern: parsePattern("/sessions/:id/attachments"), + path: "/sessions/:id/attachments", authorization: requirePermission("sessions.collaborate"), handler: handleAttachmentPost, }) @@ -252,7 +251,7 @@ export const sessionAttachmentRoutes: Route[] = [ GITHUB_SANDBOX_FALLBACK_ROUTE, sessionRoute({ method: "GET", - pattern: parsePattern("/sessions/:id/attachments/:attachmentId"), + path: "/sessions/:id/attachments/:attachmentId", authorization: requirePermission("sessions.read"), handler: handleAttachmentGet, }) diff --git a/packages/control-plane/src/routes/session-child-spawn.ts b/packages/control-plane/src/routes/session-child-spawn.ts index 1258edc527..5e25679140 100644 --- a/packages/control-plane/src/routes/session-child-spawn.ts +++ b/packages/control-plane/src/routes/session-child-spawn.ts @@ -32,7 +32,6 @@ import { error, GITHUB_SANDBOX_FALLBACK_ROUTE, json, - parsePattern, permissionRequirement, requireAll, type Route, @@ -354,7 +353,7 @@ async function handleSpawnChild( export const sessionChildSpawnRoutes: Route[] = defineRoutes(GITHUB_SANDBOX_FALLBACK_ROUTE, [ sessionRoute({ method: "POST", - pattern: parsePattern("/sessions/:id/children"), + path: "/sessions/:id/children", authorization: requireAll( permissionRequirement("sessions.create"), permissionRequirement("sessions.collaborate") diff --git a/packages/control-plane/src/routes/session-children.ts b/packages/control-plane/src/routes/session-children.ts index 1cfcf250b5..14fe3c065f 100644 --- a/packages/control-plane/src/routes/session-children.ts +++ b/packages/control-plane/src/routes/session-children.ts @@ -17,7 +17,6 @@ import { GITHUB_SANDBOX_FALLBACK_ROUTE, json, NO_AUTHORIZATION, - parsePattern, requirePermission, SCM_AGNOSTIC_SANDBOX_ROUTE, type RequestContext, @@ -264,7 +263,7 @@ export async function handleCancelChild( export const sessionChildRoutes: Route[] = [ defineRoute(GITHUB_SANDBOX_FALLBACK_ROUTE, { method: "GET", - pattern: parsePattern("/sessions/:id/children"), + path: "/sessions/:id/children", authorization: requirePermission("sessions.read"), handler: handleListChildren, }), @@ -272,7 +271,7 @@ export const sessionChildRoutes: Route[] = [ GITHUB_SANDBOX_FALLBACK_ROUTE, sessionRoute({ method: "GET", - pattern: parsePattern("/sessions/:id/children/:childId"), + path: "/sessions/:id/children/:childId", authorization: requirePermission("sessions.read"), handler: handleGetChild, }) @@ -281,7 +280,7 @@ export const sessionChildRoutes: Route[] = [ GITHUB_SANDBOX_FALLBACK_ROUTE, sessionRoute({ method: "POST", - pattern: parsePattern("/sessions/:id/children/:childId/cancel"), + path: "/sessions/:id/children/:childId/cancel", authorization: requirePermission("sessions.lifecycle"), handler: handleCancelChild, }) @@ -290,7 +289,7 @@ export const sessionChildRoutes: Route[] = [ SCM_AGNOSTIC_SANDBOX_ROUTE, sessionRoute({ method: "POST", - pattern: parsePattern("/sessions/:id/children/:childId/prompt"), + path: "/sessions/:id/children/:childId/prompt", authorization: NO_AUTHORIZATION, handler: handlePromptChild, }) diff --git a/packages/control-plane/src/routes/session-create.ts b/packages/control-plane/src/routes/session-create.ts index 550f885d04..07d6bdf3f8 100644 --- a/packages/control-plane/src/routes/session-create.ts +++ b/packages/control-plane/src/routes/session-create.ts @@ -3,7 +3,10 @@ import { getValidModelOrDefault, isValidReasoningEffort } from "@open-inspect/sh import type { CreateSessionResponse } from "@open-inspect/shared/types/session-api"; import { generateId } from "../auth/crypto"; import { resolveGitHubCredentialAuthority } from "../source-control/github-credential-authority"; -import { applyIdentityEnforcement, resolveCanonicalUserId } from "../auth/identity-enforcement"; +import { + applyIdentityEnforcement, + requireAdmittedCanonicalUserId, +} from "../routing/identity-enforcement"; import { resolveEnvironmentTarget, resolveSessionRepositories } from "../repos/resolve"; import { resolveScmProviderFromEnv } from "../source-control"; import { EnvironmentStore } from "../db/environments"; @@ -25,13 +28,13 @@ import { import { error, json, - parsePattern, resolveRepoOrError, type RequestContext, type Route, GITHUB_USER_OR_SERVICE_ROUTE, defineRoutes, requirePermission, + type ServiceActorProfileClaims, } from "./shared"; const logger = createLogger("router:session-create"); @@ -40,6 +43,25 @@ const INVALID_SESSION_REQUEST_BODY_ERROR = "Invalid session request body"; // Defense in depth on top of schema validation — matches git ref charsets. const BRANCH_NAME_PATTERN = /^[\w.\-/]+$/; +async function extractSessionActorProfileClaims( + request: Request, + ctx: RequestContext +): Promise { + const parsed = await parseCreateSessionInput(request); + if (!parsed.ok) return null; + + // Keep the admission-time claim view aligned with the handler's raw-body + // identity guard. Invalid input remains handler-owned and yields no claims. + const enforcement = applyIdentityEnforcement(ctx, "session-create", parsed.raw); + if (enforcement.rejection) return null; + + return { + displayName: parsed.input.actorDisplayName, + email: parsed.input.actorEmail, + avatarUrl: parsed.input.actorAvatarUrl, + }; +} + async function handleCreateSession( request: Request, env: Env, @@ -125,16 +147,13 @@ async function handleCreateSession( const participantUserId = enforced.participantUserId; const spawnSource = enforced.spawnSource ?? undefined; - // Resolve canonical user model ID (for D1 session index) from the verified - // principal, failing closed; body display fields stay cosmetic. + // Admission finalized the canonical subject before RBAC. The handler may + // consume only that exact subject; it must never perform late identity + // selection from body profile fields. const userStore = new UserStore(ctx.db); - const resolution = await resolveCanonicalUserId(userStore, ctx, enforced, { - displayName: body.actorDisplayName, - email: body.actorEmail, - avatarUrl: body.actorAvatarUrl, - }); + const resolution = requireAdmittedCanonicalUserId(ctx, enforced); if (resolution instanceof Response) return resolution; - const resolvedUserId = resolution.userId; + const resolvedUserId = resolution; const githubDeployment = resolveScmProviderFromEnv(env.SCM_PROVIDER) === "github"; let scmLogin = body.scmLogin; @@ -273,8 +292,9 @@ async function handleCreateSession( export const sessionCreateRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ { method: "POST", - pattern: parsePattern("/sessions"), + path: "/sessions", authorization: requirePermission("sessions.create"), + serviceActorClaims: extractSessionActorProfileClaims, handler: handleCreateSession, }, ]); diff --git a/packages/control-plane/src/routes/session-diffs.ts b/packages/control-plane/src/routes/session-diffs.ts index 3a1fa4603a..c66b1adf5f 100644 --- a/packages/control-plane/src/routes/session-diffs.ts +++ b/packages/control-plane/src/routes/session-diffs.ts @@ -12,7 +12,6 @@ import { SCM_AGNOSTIC_SANDBOX_FALLBACK_ROUTE, SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, requirePermission, - parsePattern, type Route, } from "./shared"; import { sessionRoute, type SessionRouteContext } from "./session-route"; @@ -193,7 +192,7 @@ export const sessionDiffRoutes: Route[] = [ SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, sessionRoute({ method: "GET", - pattern: parsePattern("/sessions/:id/diff"), + path: "/sessions/:id/diff", authorization: requirePermission("sessions.read"), handler: handleDiffState, }) @@ -202,7 +201,7 @@ export const sessionDiffRoutes: Route[] = [ SCM_AGNOSTIC_SANDBOX_FALLBACK_ROUTE, sessionRoute({ method: "PUT", - pattern: parsePattern("/sessions/:id/diff"), + path: "/sessions/:id/diff", authorization: requirePermission("sessions.collaborate"), handler: handleDiffUpload, }) @@ -211,7 +210,7 @@ export const sessionDiffRoutes: Route[] = [ SCM_AGNOSTIC_SANDBOX_FALLBACK_ROUTE, sessionRoute({ method: "POST", - pattern: parsePattern("/sessions/:id/diff/failure"), + path: "/sessions/:id/diff/failure", authorization: requirePermission("sessions.collaborate"), handler: handleDiffFailure, }) @@ -220,7 +219,7 @@ export const sessionDiffRoutes: Route[] = [ SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, sessionRoute({ method: "GET", - pattern: parsePattern("/sessions/:id/diff/:revisionId/files/:fileId"), + path: "/sessions/:id/diff/:revisionId/files/:fileId", authorization: requirePermission("sessions.read"), handler: handleDiffFile, }) @@ -229,7 +228,7 @@ export const sessionDiffRoutes: Route[] = [ SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, sessionRoute({ method: "POST", - pattern: parsePattern("/sessions/:id/diff/retry"), + path: "/sessions/:id/diff/retry", authorization: requirePermission("sessions.lifecycle"), handler: handleDiffRetry, }) diff --git a/packages/control-plane/src/routes/session-index.ts b/packages/control-plane/src/routes/session-index.ts index e12522ce87..d69c0359d0 100644 --- a/packages/control-plane/src/routes/session-index.ts +++ b/packages/control-plane/src/routes/session-index.ts @@ -17,7 +17,6 @@ import { GITHUB_USER_OR_SERVICE_ROUTE, json, parseJsonBody, - parsePattern, SCM_AGNOSTIC_HUMAN_USER_ROUTE, requirePermission, type RequestContext, @@ -239,25 +238,25 @@ async function handleDeleteSession( export const sessionIndexRoutes: Route[] = [ defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, { method: "GET", - pattern: parsePattern("/sessions"), + path: "/sessions", authorization: requirePermission("sessions.read"), handler: handleListSessions, }), defineRoute(SCM_AGNOSTIC_HUMAN_USER_ROUTE, { method: "GET", - pattern: parsePattern("/sessions/inbox"), + path: "/sessions/inbox", authorization: requirePermission("sessions.read", { service: "deny" }), handler: handleListSessionInbox, }), defineRoute(SCM_AGNOSTIC_HUMAN_USER_ROUTE, { method: "PATCH", - pattern: parsePattern("/sessions/:id/read-state"), + path: "/sessions/:id/read-state", authorization: requirePermission("sessions.read"), handler: handlePatchReadState, }), defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, { method: "DELETE", - pattern: parsePattern("/sessions/:id"), + path: "/sessions/:id", authorization: requirePermission("sessions.delete"), handler: handleDeleteSession, }), diff --git a/packages/control-plane/src/routes/session-media-stream.ts b/packages/control-plane/src/routes/session-media-stream.ts index 6204d75b58..f3b5e964f0 100644 --- a/packages/control-plane/src/routes/session-media-stream.ts +++ b/packages/control-plane/src/routes/session-media-stream.ts @@ -14,7 +14,6 @@ import { defineRoutes, error, GITHUB_USER_OR_SERVICE_ROUTE, - parsePattern, requirePermission, type Route, } from "./shared"; @@ -145,7 +144,7 @@ async function handleMediaGet( export const sessionMediaStreamRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ sessionRoute({ method: "GET", - pattern: parsePattern("/sessions/:id/media/:artifactId"), + path: "/sessions/:id/media/:artifactId", authorization: requirePermission("sessions.read", { actorlessGrants: [{ service: "slack-bot" }], }), diff --git a/packages/control-plane/src/routes/session-media-upload.ts b/packages/control-plane/src/routes/session-media-upload.ts index 77164f10fd..b4317c7d08 100644 --- a/packages/control-plane/src/routes/session-media-upload.ts +++ b/packages/control-plane/src/routes/session-media-upload.ts @@ -26,7 +26,6 @@ import { error, GITHUB_SANDBOX_FALLBACK_ROUTE, json, - parsePattern, requirePermission, type Route, } from "./shared"; @@ -250,7 +249,7 @@ async function handleVideoUpload(input: { export const sessionMediaUploadRoutes: Route[] = defineRoutes(GITHUB_SANDBOX_FALLBACK_ROUTE, [ sessionRoute({ method: "POST", - pattern: parsePattern("/sessions/:id/media"), + path: "/sessions/:id/media", authorization: requirePermission("sessions.collaborate"), handler: handleMediaUpload, }), diff --git a/packages/control-plane/src/routes/session-prompt.ts b/packages/control-plane/src/routes/session-prompt.ts index 5916dfeea0..41072f2279 100644 --- a/packages/control-plane/src/routes/session-prompt.ts +++ b/packages/control-plane/src/routes/session-prompt.ts @@ -8,7 +8,10 @@ import { sessionAttachmentReferencesSchema, type SessionAttachmentReference, } from "@open-inspect/shared/types/session-attachments"; -import { applyIdentityEnforcement, mayAttachCallbackContext } from "../auth/identity-enforcement"; +import { + applyIdentityEnforcement, + mayAttachCallbackContext, +} from "../routing/identity-enforcement"; import { resolveGitHubCredentialAuthority } from "../source-control/github-credential-authority"; import { SessionIndexStore } from "../db/session-index"; import { UserStore } from "../db/user-store"; @@ -25,7 +28,6 @@ import { defineRoutes, error, GITHUB_USER_OR_SERVICE_ROUTE, - parsePattern, requirePermission, type Route, } from "./shared"; @@ -181,7 +183,7 @@ async function handleSessionPrompt( export const sessionPromptRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ sessionRoute({ method: "POST", - pattern: parsePattern("/sessions/:id/prompt"), + path: "/sessions/:id/prompt", authorization: requirePermission("sessions.collaborate"), handler: handleSessionPrompt, }), diff --git a/packages/control-plane/src/routes/session-pull-requests.ts b/packages/control-plane/src/routes/session-pull-requests.ts index dd56af8c3b..f42b66ccff 100644 --- a/packages/control-plane/src/routes/session-pull-requests.ts +++ b/packages/control-plane/src/routes/session-pull-requests.ts @@ -4,7 +4,6 @@ import { defineRoutes, error, GITHUB_USER_OR_SERVICE_ROUTE, - parsePattern, requirePermission, type Route, } from "./shared"; @@ -33,7 +32,7 @@ async function handleRefreshPullRequests( export const sessionPullRequestRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ sessionRoute({ method: "POST", - pattern: parsePattern("/sessions/:id/pull-requests/refresh"), + path: "/sessions/:id/pull-requests/refresh", authorization: requirePermission("sessions.lifecycle"), handler: handleRefreshPullRequests, }), diff --git a/packages/control-plane/src/routes/session-runtime-proxy.ts b/packages/control-plane/src/routes/session-runtime-proxy.ts index ca926fe89b..39278cb60e 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.ts @@ -20,7 +20,6 @@ import { GITHUB_USER_OR_SERVICE_ROUTE, NO_AUTHORIZATION, parseJsonBody, - parsePattern, requirePermission, SCM_AGNOSTIC_SANDBOX_FALLBACK_ROUTE, SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE, @@ -70,7 +69,7 @@ function simpleProxyRoute(config: SimpleProxyRouteConfig): Route { config.policy, sessionRoute({ method: config.method, - pattern: parsePattern(config.routePath), + path: config.routePath, authorization: config.authorization, handler: async (request, _env, match, ctx) => { const sessionId = getSessionId(match); @@ -102,7 +101,7 @@ function legacyTokenRefreshRoute( SCM_AGNOSTIC_SANDBOX_ROUTE, sessionRoute({ method: "POST", - pattern: parsePattern(routePath), + path: routePath, authorization: NO_AUTHORIZATION, handler: async (_request, _env, match, ctx) => { const sessionId = getSessionId(match); @@ -293,7 +292,7 @@ function lifecycleProxyRoute( GITHUB_USER_OR_SERVICE_ROUTE, sessionRoute({ method, - pattern: parsePattern(routePath), + path: routePath, authorization: requirePermission("sessions.lifecycle"), handler: async (request, _env, match, ctx) => { const sessionId = getSessionId(match); @@ -328,7 +327,7 @@ export const sessionRuntimeProxyRoutes: Route[] = [ SCM_AGNOSTIC_HUMAN_USER_ROUTE, sessionRoute({ method: "GET", - pattern: parsePattern("/sessions/:id"), + path: "/sessions/:id", authorization: requirePermission("sessions.read"), handler: handleSessionSnapshot, }) @@ -347,7 +346,7 @@ export const sessionRuntimeProxyRoutes: Route[] = [ SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE, sessionRoute({ method: "POST", - pattern: parsePattern("/sessions/:id/sandbox-error"), + path: "/sessions/:id/sandbox-error", authorization: NO_AUTHORIZATION, handler: handleSandboxError, }) @@ -382,7 +381,7 @@ export const sessionRuntimeProxyRoutes: Route[] = [ SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, sessionRoute({ method: "GET", - pattern: parsePattern("/sessions/:id/participant-profiles"), + path: "/sessions/:id/participant-profiles", authorization: requirePermission("sessions.read"), handler: handleParticipantProfiles, }) @@ -399,7 +398,7 @@ export const sessionRuntimeProxyRoutes: Route[] = [ GITHUB_SANDBOX_FALLBACK_ROUTE, sessionRoute({ method: "POST", - pattern: parsePattern("/sessions/:id/pr"), + path: "/sessions/:id/pr", authorization: requirePermission("sessions.collaborate"), handler: handleCreatePR, }) diff --git a/packages/control-plane/src/routes/session-skills.ts b/packages/control-plane/src/routes/session-skills.ts index 91743fad10..ceb48d812b 100644 --- a/packages/control-plane/src/routes/session-skills.ts +++ b/packages/control-plane/src/routes/session-skills.ts @@ -5,7 +5,6 @@ import { defineRoute, error, json, - parsePattern, NO_AUTHORIZATION, requirePermission, SCM_AGNOSTIC_SANDBOX_ROUTE, @@ -80,13 +79,13 @@ async function handleSandboxInstallation( export const sessionSkillRoutes: Route[] = [ defineRoute(SCM_AGNOSTIC_HUMAN_USER_ROUTE, { method: "GET", - pattern: parsePattern("/sessions/:id/skills"), + path: "/sessions/:id/skills", authorization: requirePermission("sessions.read"), handler: handleSessionSkillsView, }), defineRoute(SCM_AGNOSTIC_SANDBOX_ROUTE, { method: "GET", - pattern: parsePattern("/sessions/:id/sandbox-skills"), + path: "/sessions/:id/sandbox-skills", authorization: NO_AUTHORIZATION, handler: handleSandboxInstallation, }), diff --git a/packages/control-plane/src/routes/session-ws-token.ts b/packages/control-plane/src/routes/session-ws-token.ts index 8e959cde6c..58ce8d1ded 100644 --- a/packages/control-plane/src/routes/session-ws-token.ts +++ b/packages/control-plane/src/routes/session-ws-token.ts @@ -1,4 +1,4 @@ -import { applyIdentityEnforcement } from "../auth/identity-enforcement"; +import { applyIdentityEnforcement } from "../routing/identity-enforcement"; import { SESSION_WEBSOCKET_CONNECT_PERMISSION } from "@open-inspect/shared/rbac"; import { SessionInternalPaths, sessionScmDisplayFieldsSchema } from "../session/contracts"; import type { Env } from "../types"; @@ -7,7 +7,6 @@ import { error, GITHUB_USER_OR_SERVICE_ROUTE, parseJsonBody, - parsePattern, requirePermission, type Route, } from "./shared"; @@ -58,7 +57,7 @@ async function handleSessionWsToken( export const sessionWsTokenRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [ sessionRoute({ method: "POST", - pattern: parsePattern("/sessions/:id/ws-token"), + path: "/sessions/:id/ws-token", authorization: requirePermission(SESSION_WEBSOCKET_CONNECT_PERMISSION), handler: handleSessionWsToken, }), diff --git a/packages/control-plane/src/routes/shared.ts b/packages/control-plane/src/routes/shared.ts index 9560f71023..da4b83dbe2 100644 --- a/packages/control-plane/src/routes/shared.ts +++ b/packages/control-plane/src/routes/shared.ts @@ -3,21 +3,13 @@ */ import { decodeRepositoryPathSegments } from "@open-inspect/shared/types/repositories"; -import type { CorrelationContext } from "../logger"; -import type { AuthenticationContext, Principal } from "../auth/principal"; -import type { RequestMetrics } from "../db/instrumented-d1"; -import type { SqlDatabase } from "../db/sql-database"; +import type { Principal } from "../auth/principal"; +import type { RequestContext } from "../http/request-context"; +import { error, HttpError } from "../http/responses"; import type { Env } from "../types"; import type { Logger } from "../logger"; -import type { BackgroundTasks } from "../platform-ports"; -import type { BetterAuthRuntime, UserAuthRuntime } from "../auth/user/runtime"; -import type { - EffectiveAuthorization, - PermissionId, - ScopedPermissionStem, -} from "@open-inspect/shared/rbac"; +import type { PermissionId, ScopedPermissionStem } from "@open-inspect/shared/rbac"; import type { ServiceName } from "@open-inspect/shared/service-auth"; -import type { AutomationRow } from "../db/automation-store"; import { createSourceControlProviderFromEnv, SourceControlProviderError, @@ -26,46 +18,32 @@ import { type SourceControlProviderName, } from "../source-control"; -/** Request-scoped dependencies, identity, and resolved authorization state. */ -export type RequestContext = CorrelationContext & { - metrics: RequestMetrics; - /** - * The request's database handle (the DB binding wrapped with query - * instrumentation). Route handlers must use this instead of the raw binding - * so every query is timed — an ESLint rule forbids `.DB` access under - * src/routes and src/webhooks. - */ - db: SqlDatabase; - /** Request-scoped capability for scheduling background tasks. */ - executionCtx: BackgroundTasks; - /** Lazy runtime dependency used by user-session authentication and credential access. */ - getUserAuth?: () => BetterAuthRuntime; - /** Lazy normalized auth runtime used by server-only authentication composition routes. */ - getUserAuthRuntime?: () => UserAuthRuntime; - /** - * The request's verified principal. Absent only on public routes and CORS - * preflights — every authenticated request carries one. - */ - principal?: Principal; - /** Authentication provenance, separate from the principal being authorized. */ - authentication?: AuthenticationContext; - /** Effective human authorization loaded once by the router for this request. */ - authorization?: EffectiveAuthorization; - /** Resource admission populated by the router for automation mutation routes. */ - automationAdmission?: AutomationRouteAdmission; -}; +export type { AutomationRouteAdmission, RequestContext } from "../http/request-context"; +export { error, HttpError, json } from "../http/responses"; -/** Automation resource admitted by the router for the current mutation. */ -export interface AutomationRouteAdmission { - automation: AutomationRow; +/** Profile data a route can extract from an already verified service request. */ +export interface ServiceActorProfileClaims { + displayName?: string; + email?: string; + avatarUrl?: string; } /** Route matching, authorization, and handler configuration. */ export interface RouteDefinition { method: string; - pattern: RegExp; + path: string; /** Authorization policy enforced before the handler runs. */ authorization: RouteAuthorization; + /** + * Extract profile claims asserted by the trusted service that owns this + * route. Authentication has already verified the exact request body before + * this hook runs. Invalid route input returns no claims so the handler keeps + * ownership of its existing validation response. + */ + serviceActorClaims?: ( + request: Request, + ctx: RequestContext + ) => Promise; cacheControl?: "no-store" | "private, no-store"; handler: (request: Request, env: Env, match: RegExpMatchArray, ctx: Context) => Promise; } @@ -299,7 +277,16 @@ export interface RoutePolicy { supportedScmProviders: "all" | readonly SourceControlProviderName[]; } -export interface Route extends RouteDefinition, RoutePolicy {} +/** Fully resolved route, including the raw-path matcher compiled from its canonical path. */ +export interface Route extends RouteDefinition, RoutePolicy { + pattern: RegExp; +} + +/** Framework-neutral policy consumed by request admission. */ +export type RouteAdmissionPolicy = Pick< + Route, + "authentication" | "authorization" | "serviceActorClaims" | "supportedScmProviders" +>; const SESSION_ID_BINDING: SandboxSessionBinding = { getSessionId: (match) => match.groups?.id ?? null, @@ -368,7 +355,12 @@ export function defineRoute( ): Route { const handler: Route["handler"] = (request, env, match, ctx) => route.handler(request, env, match, ctx as RouteContext); - return { ...route, ...policy, handler }; + return { + ...route, + ...policy, + pattern: parsePattern(route.path), + handler, + }; } /** @@ -379,38 +371,6 @@ export function parsePattern(pattern: string): RegExp { return new RegExp(`^${regexPattern}$`); } -/** - * Create JSON response. - */ -export function json(data: unknown, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -/** - * Create error response. - */ -export function error(message: string, status = 400): Response { - return json({ error: message }, status); -} - -/** - * Raise from a route handler or helper to return an error response with a - * specific status. Mapped centrally in router.ts's dispatch catch to - * error(message, status), avoiding `| Response` plumbing in callers. - */ -export class HttpError extends Error { - constructor( - message: string, - readonly status: number - ) { - super(message); - this.name = "HttpError"; - } -} - /** * Create a SourceControlProvider for use in Worker-level route handlers. * Cheap to construct (no I/O), so creating per-request is fine. diff --git a/packages/control-plane/src/routes/sign-in-providers.ts b/packages/control-plane/src/routes/sign-in-providers.ts index 15058e7dd4..b91f30ee44 100644 --- a/packages/control-plane/src/routes/sign-in-providers.ts +++ b/packages/control-plane/src/routes/sign-in-providers.ts @@ -5,7 +5,6 @@ import { error, json, NO_AUTHORIZATION, - parsePattern, SCM_AGNOSTIC_WEB_SERVICE_ROUTE, type Route, } from "./shared"; @@ -39,7 +38,7 @@ const handleSignInProviders: Route["handler"] = async (_request, _env, _match, c export const signInProviderRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_WEB_SERVICE_ROUTE, [ { method: "GET", - pattern: parsePattern("/internal/auth/sign-in-providers"), + path: "/internal/auth/sign-in-providers", authorization: NO_AUTHORIZATION, handler: handleSignInProviders, }, diff --git a/packages/control-plane/src/routes/skills.ts b/packages/control-plane/src/routes/skills.ts index 84e3c3be3e..3959fb4c52 100644 --- a/packages/control-plane/src/routes/skills.ts +++ b/packages/control-plane/src/routes/skills.ts @@ -34,7 +34,6 @@ import { createRouteSourceControlProvider, error, json, - parsePattern, type RequestContext, type Route, SCM_AGNOSTIC_HUMAN_USER_ROUTE, @@ -628,25 +627,25 @@ function profileWriteError(value: unknown): Response { const skillReadRoutes = defineRoutes(SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, [ { method: "GET", - pattern: parsePattern("/skills"), + path: "/skills", authorization: requirePermission("skills.read"), handler: handleListSkills, }, { method: "POST", - pattern: parsePattern("/skills/preview"), + path: "/skills/preview", authorization: requirePermission("skills.read"), handler: handlePreviewSkill, }, { method: "POST", - pattern: parsePattern("/skills/resolve-preview"), + path: "/skills/resolve-preview", authorization: requirePermission("skills.read"), handler: handleResolvePreview, }, { method: "GET", - pattern: parsePattern("/skills/:id"), + path: "/skills/:id", authorization: requirePermission("skills.read"), handler: handleGetSkill, }, @@ -655,73 +654,73 @@ const skillReadRoutes = defineRoutes(SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, [ const skillAdministrationRoutes = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [ { method: "POST", - pattern: parsePattern("/skills"), + path: "/skills", authorization: requirePermission("skills.manage"), handler: handleCreateSkill, }, { method: "POST", - pattern: parsePattern("/skills/import/preview"), + path: "/skills/import/preview", authorization: requirePermission("skills.manage"), handler: handlePreviewSkillImport, }, { method: "POST", - pattern: parsePattern("/skills/import"), + path: "/skills/import", authorization: requirePermission("skills.manage"), handler: handleImportSkill, }, { method: "POST", - pattern: parsePattern("/skills/:id/reimport/preview"), + path: "/skills/:id/reimport/preview", authorization: requirePermission("skills.manage"), handler: handlePreviewSkillReimport, }, { method: "POST", - pattern: parsePattern("/skills/:id/reimport"), + path: "/skills/:id/reimport", authorization: requirePermission("skills.manage"), handler: handleReimportSkill, }, { method: "PATCH", - pattern: parsePattern("/skills/:id"), + path: "/skills/:id", authorization: requirePermission("skills.manage"), handler: handleSetSkillEnabled, }, { method: "PUT", - pattern: parsePattern("/skills/:id"), + path: "/skills/:id", authorization: requirePermission("skills.manage"), handler: handleReplaceSkillContentAndAssignments, }, { method: "DELETE", - pattern: parsePattern("/skills/:id"), + path: "/skills/:id", authorization: requirePermission("skills.manage"), handler: handleDeleteSkill, }, { method: "GET", - pattern: parsePattern("/skill-profiles"), + path: "/skill-profiles", authorization: requirePermission("skill_profiles.manage_own"), handler: handleListProfiles, }, { method: "POST", - pattern: parsePattern("/skill-profiles"), + path: "/skill-profiles", authorization: requirePermission("skill_profiles.manage_own"), handler: handleCreateProfile, }, { method: "PATCH", - pattern: parsePattern("/skill-profiles/:id"), + path: "/skill-profiles/:id", authorization: requirePermission("skill_profiles.manage_own"), handler: handleUpdateProfile, }, { method: "DELETE", - pattern: parsePattern("/skill-profiles/:id"), + path: "/skill-profiles/:id", authorization: requirePermission("skill_profiles.manage_own"), handler: handleDeleteProfile, }, diff --git a/packages/control-plane/src/routing/hono-app.ts b/packages/control-plane/src/routing/hono-app.ts new file mode 100644 index 0000000000..ba614dd376 --- /dev/null +++ b/packages/control-plane/src/routing/hono-app.ts @@ -0,0 +1,162 @@ +/** Hono adapter for ordinary control-plane HTTP requests. */ + +import { Hono } from "hono"; +import { TrieRouter } from "hono/router/trie-router"; +import { createCloudflareBackgroundTasks } from "../cloudflare/background-tasks"; +import { createRequestContext } from "../http/create-request-context"; +import type { RequestContext } from "../http/request-context"; +import { error } from "../http/responses"; +import { createLogger } from "../logger"; +import { routes } from "../routes/catalog"; +import type { Route } from "../routes/shared"; +import { dispatchMatchedRoute } from "./route-dispatch"; +import { withCorsAndTraceHeaders } from "./request-lifecycle"; +import type { Env } from "../types"; + +type ControlPlaneHonoEnv = { + Bindings: Env; + Variables: { + requestContext: RequestContext; + startedAt: number; + }; +}; + +/** Ordinary HTTP entrypoint signature shared by the Worker and test adapters. */ +export type ControlPlaneHttpHandler = ( + request: Request, + env: Env, + executionCtx: ExecutionContext +) => Promise; + +const logger = createLogger("router"); + +/** + * Hono gives `*`, `?`, `{...}` and `.` routing meaning that parsePattern + * compiles as literals. Refusing anything outside literal or `:param` + * segments keeps Hono selection and the raw-path regex in agreement. + */ +const ROUTE_PATH_GRAMMAR = /^(\/([A-Za-z0-9_-]+|:\w+))+$/; + +function createHonoApp(catalog: readonly Route[]): Hono { + for (const route of catalog) { + if (!ROUTE_PATH_GRAMMAR.test(route.path)) { + throw new Error(`Route path is outside the supported grammar: ${route.method} ${route.path}`); + } + } + + const app = new Hono({ + strict: true, + getPath: (request) => new URL(request.url).pathname, + router: new TrieRouter(), + }); + + app.onError((caught) => { + throw caught; + }); + + app.use("*", async (c, next) => { + // TrieRouter runs a root wildcard twice for the literal path `/*`. + if (c.get("requestContext")) return next(); + const startedAt = Date.now(); + // eslint-disable-next-line no-restricted-syntax -- Hono composition root passes the stable binding once + const database = c.env.DB; + const context = createRequestContext({ + request: c.req.raw, + env: c.env, + database, + executionCtx: createCloudflareBackgroundTasks(c.executionCtx), + }); + c.set("requestContext", context); + c.set("startedAt", startedAt); + await next(); + }); + + app.options("*", (c) => { + const context = c.get("requestContext"); + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + "x-request-id": context.request_id, + "x-trace-id": context.trace_id, + }, + }); + }); + + for (const route of catalog) { + app.on(route.method, route.path, async (c) => { + const pathname = new URL(c.req.raw.url).pathname; + const match = pathname.match(route.pattern); + const context = c.get("requestContext"); + if (!match) { + // Unreachable while the grammar guard holds; fail closed but loudly. + logger.error("Hono selected a route its raw-path matcher rejects", { + event: "router.match_mismatch", + http_method: route.method, + route_path: route.path, + http_path: pathname, + request_id: context.request_id, + trace_id: context.trace_id, + }); + return withCorsAndTraceHeaders(error("Not found", 404), context); + } + + return dispatchMatchedRoute({ + request: c.req.raw, + env: c.env, + route, + match, + pathname, + context, + startedAt: c.get("startedAt"), + }); + }); + } + + app.notFound((c) => withCorsAndTraceHeaders(error("Not found", 404), c.get("requestContext"))); + + return app; +} + +/** + * Build the ordinary HTTP entrypoint over a route catalog. + * + * DB and HEAD handling stay outside Hono because missing-DB responses are + * intentionally undecorated and Hono implicitly maps HEAD to GET. + */ +export function createControlPlaneHttpHandler(catalog: readonly Route[]): ControlPlaneHttpHandler { + const app = createHonoApp(catalog); + + return async (request, env, executionCtx) => { + const pathname = new URL(request.url).pathname; + + // eslint-disable-next-line no-restricted-syntax -- ordinary HTTP composition root validates the required binding + if (!env.DB) { + logger.error("DB binding is not configured; refusing request", { http_path: pathname }); + return new Response(JSON.stringify({ error: "Database not configured" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); + } + + if (request.method === "HEAD") { + // eslint-disable-next-line no-restricted-syntax -- ordinary HTTP composition root passes the stable binding once + const database = env.DB; + const context = createRequestContext({ + request, + env, + database, + executionCtx: createCloudflareBackgroundTasks(executionCtx), + }); + return withCorsAndTraceHeaders(error("Not found", 404), context); + } + + return app.fetch(request, env, executionCtx); + }; +} + +/** Production entrypoint over the canonical route catalog. */ +export const handleControlPlaneHttp: ControlPlaneHttpHandler = + createControlPlaneHttpHandler(routes); diff --git a/packages/control-plane/src/auth/identity-enforcement.ts b/packages/control-plane/src/routing/identity-enforcement.ts similarity index 64% rename from packages/control-plane/src/auth/identity-enforcement.ts rename to packages/control-plane/src/routing/identity-enforcement.ts index 66858c31d0..fe7972d25b 100644 --- a/packages/control-plane/src/auth/identity-enforcement.ts +++ b/packages/control-plane/src/routing/identity-enforcement.ts @@ -11,11 +11,11 @@ import type { SpawnSource } from "@open-inspect/shared/types/sessions"; import type { ServiceName } from "@open-inspect/shared/service-auth"; -import { createLogger } from "./../logger"; -import { CALLBACK_DESTINATIONS } from "./service/callback-signing"; -import type { Principal, ResolvedIdentity } from "./principal"; -import type { UserStore } from "../db/user-store"; -import { error, json, type RequestContext } from "../routes/shared"; +import { createLogger } from "../logger"; +import { CALLBACK_DESTINATIONS } from "../auth/service/callback-signing"; +import type { Principal, ResolvedIdentity } from "../auth/principal"; +import { error } from "../http/responses"; +import type { RequestContext } from "../http/request-context"; const logger = createLogger("identity-enforcement"); @@ -36,9 +36,10 @@ const SPAWNING_FORBIDDEN_FIELDS = [ /** * Raw-body keys a caller may not send: identity comes from the principal, * SCM credentials from server-side enrichment. Checked against raw JSON - * before Zod because every schema is strip-mode. Display-only fields + * before Zod because every schema is strip-mode. Profile fields * (authEmail/Name/AvatarUrl, actorDisplayName, scmLogin…) stay body-carried - * by design. + * by design; only admission for a verified Slack/Linear service may treat + * actorEmail as identity-bearing. */ const FORBIDDEN_IDENTITY_FIELDS: Record = { "session-create": SPAWNING_FORBIDDEN_FIELDS, @@ -73,8 +74,8 @@ interface DerivedIdentity { /** Canonical D1 users.id when the principal resolves to one. */ canonicalUserId: string | null; /** - * The verified bot-asserted actor backing `participantUserId` — what - * `resolveCanonicalUserId` creates the canonical user from on first sight. + * The verified bot-asserted actor backing `participantUserId` — what route + * admission uses to finalize the canonical user before RBAC. * Null for user principals (their `canonicalUserId` is always set) and for * userless service principals. */ @@ -175,85 +176,25 @@ export function applyIdentityEnforcement( } /** - * Resolve the canonical `users.id` for a spawning route, creating the user - * from the VERIFIED actor when the CP has not seen them before (display - * fields may come from the body — they are cosmetic, never identity). Fails - * closed with a 500 rather than writing anonymous attribution. Shared by - * session-create and automation-create so the two routes cannot drift. - * - * Takes the requires-user enforced shape: every participant is backed by a - * canonical user (web users) or a verified actor (bot assertions), so the - * resolved id is never null. + * Return the canonical subject already admitted by the router. Spawning + * handlers may never resolve or relink identity after RBAC has run: the user + * authorized and the user attributed to the side effect must be identical. */ -export async function resolveCanonicalUserId( - userStore: UserStore, +export function requireAdmittedCanonicalUserId( ctx: RequestContext, - enforced: DerivedIdentity & { participantUserId: string }, - display: { displayName?: string; email?: string; avatarUrl?: string } -): Promise<{ userId: string } | Response> { - const requireActive = async (userId: string): Promise<{ userId: string } | Response> => { - try { - const active = await ctx.db - .prepare("SELECT 1 AS active FROM users WHERE id = ? AND suspended_at IS NULL") - .bind(userId) - .first<{ active: number }>(); - return active ? { userId } : error("Workspace access is disabled", 403); - } catch (cause) { - logger.error("Failed to verify workspace access", { - error: cause instanceof Error ? cause : String(cause), - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return error("Authorization unavailable", 503); - } - }; - if (enforced.canonicalUserId) return requireActive(enforced.canonicalUserId); - const actor = enforced.actor; - if (!actor) { - // Unreachable while deriveIdentity holds its invariant (a participant - // without a canonical user is always actor-backed); fail closed rather - // than write anonymous attribution if that ever breaks. - logger.error("Participant carries neither a canonical user nor an actor", { - participant: enforced.participantUserId, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return error("Failed to resolve session identity", 500); - } - try { - const user = await userStore.resolveOrCreateUser({ - provider: actor.provider, - providerUserId: actor.providerUserId, - displayName: display.displayName, - providerEmail: display.email, - avatarUrl: display.avatarUrl, - }); - if (ctx.authorization && user.id !== ctx.authorization.userId) { - logMismatchRejected( - "actor-resolution", - "canonicalUserId", - ctx.authorization.userId, - user.id, - ctx - ); - return json( - { - error: "Actor identity changed; retry the request", - code: "actor_identity_changed", - }, - 409 - ); - } - return requireActive(user.id); - } catch (e) { - logger.error("Failed to resolve verified actor identity", { - error: e instanceof Error ? e : String(e), - provider: actor.provider, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return error("Failed to resolve session identity", 500); - } + enforced: DerivedIdentity & { participantUserId: string } +): string | Response { + const userId = enforced.canonicalUserId; + if (userId && ctx.authorization?.userId === userId) return userId; + + logger.error("Spawning handler received no matching admitted canonical user", { + participant: enforced.participantUserId, + canonical_user_id: userId ?? undefined, + authorized_user_id: ctx.authorization?.userId, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return error("Failed to resolve session identity", 500); } /** @@ -268,21 +209,3 @@ export function mayAttachCallbackContext(ctx: RequestContext): boolean { (CALLBACK_DESTINATIONS as readonly ServiceName[]).includes(principal.service) ); } - -function logMismatchRejected( - route: string, - field: string, - expected: string, - actual: string, - ctx: RequestContext -): void { - logger.warn("Identity mismatch rejected", { - event: "identity.mismatch_rejected", - route, - field, - expected, - actual, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); -} diff --git a/packages/control-plane/src/routing/request-lifecycle.test.ts b/packages/control-plane/src/routing/request-lifecycle.test.ts new file mode 100644 index 0000000000..302749ef5d --- /dev/null +++ b/packages/control-plane/src/routing/request-lifecycle.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Principal } from "../auth/principal"; +import type { RequestContext } from "../http/request-context"; +import type { Route } from "../routes/shared"; +import { + logPrincipal, + logRequest, + finalizeRouteResponse, + withCorsAndTraceHeaders, +} from "./request-lifecycle"; + +function requestContext(metrics: Record = {}): RequestContext { + return { + request_id: "request-123", + trace_id: "trace-456", + metrics: { + d1Queries: [], + spans: {}, + time: async (_name: string, operation: () => Promise): Promise => operation(), + summarize: () => metrics, + }, + } as unknown as RequestContext; +} + +function route(cacheControl?: Route["cacheControl"]): Route { + return { cacheControl } as Route; +} + +function loggedEvents(spy: ReturnType): Array> { + return spy.mock.calls.map((call: unknown[]) => { + const [line] = call; + return JSON.parse(String(line)) as Record; + }); +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("response finalization", () => { + it("adds common CORS and correlation headers without changing the response payload", async () => { + const response = new Response("created", { + status: 201, + statusText: "Created here", + headers: { + "Access-Control-Allow-Origin": "https://old.example", + "Content-Type": "text/plain", + "x-request-id": "old-request", + "x-trace-id": "old-trace", + }, + }); + + const finalized = withCorsAndTraceHeaders(response, requestContext()); + + expect(finalized).not.toBe(response); + expect(finalized.status).toBe(201); + expect(finalized.statusText).toBe("Created here"); + expect(finalized.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(finalized.headers.get("Content-Type")).toBe("text/plain"); + expect(finalized.headers.get("x-request-id")).toBe("request-123"); + expect(finalized.headers.get("x-trace-id")).toBe("trace-456"); + await expect(finalized.text()).resolves.toBe("created"); + }); + + it("applies common headers and overrides a route-owned cache policy in one pass", async () => { + const response = new Response("private", { + status: 202, + headers: { "Cache-Control": "public, max-age=3600", ETag: '"v1"' }, + }); + + const finalized = finalizeRouteResponse(response, route("private, no-store"), requestContext()); + + expect(finalized).not.toBe(response); + expect(finalized.status).toBe(202); + expect(finalized.headers.get("Cache-Control")).toBe("private, no-store"); + expect(finalized.headers.get("ETag")).toBe('"v1"'); + expect(finalized.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(finalized.headers.get("x-request-id")).toBe("request-123"); + expect(finalized.headers.get("x-trace-id")).toBe("trace-456"); + await expect(finalized.text()).resolves.toBe("private"); + }); +}); + +describe("request lifecycle logging", () => { + it.each([ + [ + { kind: "user", userId: "user-1" } satisfies Principal, + { principal_kind: "user", user_id: "user-1" }, + ], + [ + { kind: "sandbox", sessionId: "session-1" } satisfies Principal, + { principal_kind: "sandbox", session_id: "session-1" }, + ], + [ + { + kind: "service", + service: "slack-bot", + actor: { + provider: "slack", + providerUserId: "U123", + canonicalUserId: "user-1", + participantUserId: "slack:U123", + }, + } satisfies Principal, + { + principal_kind: "service", + auth_scheme: "per-service", + principal_service: "slack-bot", + actor: "slack:U123", + }, + ], + ])("logs verified %s attribution with correlation fields", (principal, expected) => { + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined); + + logPrincipal(principal, requestContext(), "/sessions/session-1"); + + expect(loggedEvents(consoleLog)).toContainEqual( + expect.objectContaining({ + event: "auth.principal", + service: "control-plane", + http_path: "/sessions/session-1", + request_id: "request-123", + trace_id: "trace-456", + ...expected, + }) + ); + }); + + it.each([ + [204, "success"], + [500, "error"], + ])("logs status %i as a %s outcome with request metrics", (status, outcome) => { + vi.useFakeTimers(); + vi.setSystemTime(1_250); + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined); + + logRequest( + new Response(null, { status }), + requestContext({ d1_query_count: 2, d1_total_ms: 7 }), + "POST", + "/sessions", + 1_000 + ); + + expect(loggedEvents(consoleLog)).toContainEqual( + expect.objectContaining({ + event: "http.request", + request_id: "request-123", + trace_id: "trace-456", + http_method: "POST", + http_path: "/sessions", + http_status: status, + duration_ms: 250, + outcome, + d1_query_count: 2, + d1_total_ms: 7, + }) + ); + }); +}); diff --git a/packages/control-plane/src/routing/request-lifecycle.ts b/packages/control-plane/src/routing/request-lifecycle.ts new file mode 100644 index 0000000000..70a1aa8226 --- /dev/null +++ b/packages/control-plane/src/routing/request-lifecycle.ts @@ -0,0 +1,84 @@ +import type { Principal } from "../auth/principal"; +import type { RequestContext } from "../http/request-context"; +import { createLogger } from "../logger"; +import type { Route } from "../routes/shared"; + +const logger = createLogger("router"); + +/** Add the response headers shared by all ordinary HTTP route responses. */ +export function withCorsAndTraceHeaders(response: Response, ctx: RequestContext): Response { + const headers = new Headers(response.headers); + headers.set("Access-Control-Allow-Origin", "*"); + headers.set("x-request-id", ctx.request_id); + headers.set("x-trace-id", ctx.trace_id); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +/** Apply all matched-route response policy in one body-preserving reconstruction. */ +export function finalizeRouteResponse( + response: Response, + route: Route, + ctx: RequestContext +): Response { + const headers = new Headers(response.headers); + headers.set("Access-Control-Allow-Origin", "*"); + headers.set("x-request-id", ctx.request_id); + headers.set("x-trace-id", ctx.trace_id); + if (route.cacheControl) headers.set("Cache-Control", route.cacheControl); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +/** Emit verified principal attribution without credential material. */ +export function logPrincipal(principal: Principal, ctx: RequestContext, path: string): void { + const fields: Record = { principal_kind: principal.kind }; + switch (principal.kind) { + case "service": + fields.auth_scheme = "per-service"; + // `service` is reserved by the shared logger, so the bot name needs its own key. + fields.principal_service = principal.service; + fields.actor = principal.actor?.participantUserId; + break; + case "sandbox": + fields.session_id = principal.sessionId; + break; + case "user": + fields.user_id = principal.userId; + break; + } + logger.info("auth.principal", { + event: "auth.principal", + ...fields, + http_path: path, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); +} + +/** Emit the request completion wide event with accumulated request metrics. */ +export function logRequest( + response: Response, + ctx: RequestContext, + method: string, + path: string, + startTime: number +): void { + logger.info("http.request", { + event: "http.request", + request_id: ctx.request_id, + trace_id: ctx.trace_id, + http_method: method, + http_path: path, + http_status: response.status, + duration_ms: Date.now() - startTime, + outcome: response.status >= 500 ? "error" : "success", + ...ctx.metrics.summarize(), + }); +} diff --git a/packages/control-plane/src/routing/route-admission.ts b/packages/control-plane/src/routing/route-admission.ts new file mode 100644 index 0000000000..9eea75d457 --- /dev/null +++ b/packages/control-plane/src/routing/route-admission.ts @@ -0,0 +1,772 @@ +/** Framework-neutral authentication and authorization for a matched route. */ + +import { + SCOPED_PERMISSION_PAIRS, + resolveScopedPermission, + type PermissionId, +} from "@open-inspect/shared/rbac"; +import { authenticate, isAuthError } from "../auth/authenticate"; +import type { Principal } from "../auth/principal"; +import type { + AuthorizationDecisionRequirement, + RouteAuthorizationDecision, +} from "../authorization/request-audit"; +import { AuthorizationError, AuthorizationService } from "../authorization/service"; +import { serviceAllowsPermission } from "../authorization/service-permissions"; +import { AutomationStore } from "../db/automation-store"; +import { UserStore } from "../db/user-store"; +import type { RequestContext } from "../http/request-context"; +import { error, json } from "../http/responses"; +import { createLogger } from "../logger"; +import type { + ActorlessServiceGrant, + RouteAdmissionPolicy, + RouteAuthentication, + RouteAuthorizationRequirement, +} from "../routes/shared"; +import { SessionInternalPaths } from "../session/contracts"; +import { createSessionRuntimeClient } from "../session/runtime-client"; +import { resolveScmProviderFromEnv, SourceControlProviderError } from "../source-control"; +import type { Env } from "../types"; +import { logPrincipal } from "./request-lifecycle"; + +const logger = createLogger("router"); + +export type AllowedAuthorizationDecision = Extract; +export type DeniedAuthorizationDecision = Extract; + +export type RouteAdmissionResult = + | { kind: "admitted"; handlerRequest: Request; decision: AllowedAuthorizationDecision } + | { + kind: "denied"; + response: Response; + requestLog: "emit" | "skip"; + /** Present for authorization denials; absent for authentication and infrastructure failures. */ + decision?: DeniedAuthorizationDecision; + }; + +/** A denial with optional audit evidence; infrastructure failures carry none. */ +export interface AuthorizationFailure { + response: Response; + decision?: DeniedAuthorizationDecision; +} + +interface AuthorizationEvidence { + requirements: AuthorizationDecisionRequirement[]; + effectivePermissions: PermissionId[]; +} + +type RouteAuthorizationResult = + | { kind: "allowed"; decision: AllowedAuthorizationDecision } + | { kind: "denied"; response: Response; decision: DeniedAuthorizationDecision } + | { kind: "error"; response: Response }; + +function denied( + response: Response, + options?: { requestLog?: "emit" | "skip"; decision?: DeniedAuthorizationDecision } +): RouteAdmissionResult { + return { + kind: "denied", + response, + requestLog: options?.requestLog ?? "emit", + ...(options?.decision ? { decision: options.decision } : {}), + }; +} + +function emptyEvidence(): AuthorizationEvidence { + return { requirements: [], effectivePermissions: [] }; +} + +function authorizationDenial( + response: Response, + evidence: AuthorizationEvidence, + failedRequirement: AuthorizationDecisionRequirement, + reasonCode: string, + reason: string, + failedPermission?: PermissionId +): AuthorizationFailure { + return { + response, + decision: { + kind: "denied", + ...evidence, + requirements: [...evidence.requirements, failedRequirement], + reasonCode, + reason, + ...(failedPermission ? { failedPermission } : {}), + }, + }; +} + +function authorizationUnavailable(): AuthorizationFailure { + return { + response: json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503), + }; +} + +function resultForFailure( + failure: AuthorizationFailure +): Exclude { + return failure.decision + ? { kind: "denied", response: failure.response, decision: failure.decision } + : { kind: "error", response: failure.response }; +} + +function enforceImplementedScmProvider( + policy: RouteAdmissionPolicy, + path: string, + env: Env, + ctx: RequestContext +): Response | null { + try { + const provider = resolveScmProviderFromEnv(env.SCM_PROVIDER); + if ( + policy.supportedScmProviders !== "all" && + !policy.supportedScmProviders.includes(provider) + ) { + logger.warn("SCM provider not implemented", { + event: "scm.provider_not_implemented", + scm_provider: provider, + http_path: path, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return error(`SCM provider '${provider}' is not implemented in this deployment.`, 501); + } + + return null; + } catch (errorValue) { + const errorMessage = + errorValue instanceof SourceControlProviderError + ? errorValue.message + : "Invalid SCM provider configuration"; + + logger.error("Invalid SCM provider configuration", { + event: "scm.provider_invalid", + error: errorValue instanceof Error ? errorValue : String(errorValue), + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + + return error(errorMessage, 500); + } +} + +async function verifySandboxAuth( + request: Request, + env: Env, + sessionId: string, + ctx: RequestContext +): Promise { + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return error("Unauthorized: Missing sandbox token", 401); + } + + const token = authHeader.slice(7); + const verifyResponse = await createSessionRuntimeClient(env, ctx).fetch( + sessionId, + SessionInternalPaths.verifySandboxToken, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + } + ); + + if (!verifyResponse.ok) { + const clientIP = request.headers.get("CF-Connecting-IP") || "unknown"; + logger.warn("Auth failed: sandbox", { + event: "auth.sandbox_failed", + http_path: new URL(request.url).pathname, + client_ip: clientIP, + session_id: sessionId, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return error("Unauthorized: Invalid sandbox token", 401); + } + + ctx.principal = { kind: "sandbox", sessionId }; + return null; +} + +async function verifySandboxAuthSafely( + request: Request, + env: Env, + sessionId: string, + ctx: RequestContext +): Promise { + try { + return await verifySandboxAuth(request, env, sessionId, ctx); + } catch (cause) { + logger.error("Sandbox authentication unavailable", { + event: "auth.sandbox_unavailable", + session_id: sessionId, + error: cause instanceof Error ? cause : String(cause), + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return error("Sandbox authentication unavailable", 503); + } +} + +/** Reject verified principals whose kind the route's authentication policy excludes. */ +export function enforceRoutePrincipal( + authentication: RouteAuthentication, + principal: Principal, + evidence: AuthorizationEvidence = emptyEvidence() +): AuthorizationFailure | null { + if ( + authentication.kind === "web-service" && + (principal.kind !== "service" || principal.service !== "web") + ) { + return { response: error("Unauthorized", 401) }; + } + if (authentication.kind === "user" && principal.kind !== "user") { + return authorizationDenial( + error("Human user authentication required", 403), + evidence, + { kind: "principal-type" }, + "principal_type_required", + "Human user authentication required" + ); + } + if (authentication.kind === "service" && principal.kind !== "service") { + return authorizationDenial( + error("Service authentication required", 403), + evidence, + { kind: "principal-type" }, + "principal_type_required", + "Service authentication required" + ); + } + return null; +} + +/** + * Load the canonical subject's effective authorization. Service actors reach + * this step already finalized, so the subject is the admitted canonical user. + */ +/** Authorization kinds whose admission loads the canonical subject's role. */ +function loadsCanonicalSubject(policy: RouteAdmissionPolicy): boolean { + return ( + policy.authorization.kind === "active-user" || + policy.authorization.kind === "active-self" || + policy.authorization.kind === "active-global" + ); +} + +async function enforceActiveUser( + policy: RouteAdmissionPolicy, + ctx: RequestContext, + evidence: AuthorizationEvidence +): Promise { + if (!loadsCanonicalSubject(policy)) return null; + if ( + ctx.principal?.kind === "service" && + ctx.principal.actor && + !ctx.principal.actor.canonicalUserId + ) { + // finalizeServiceActor runs for the same policy kinds; an unfinalized + // actor here means enrollment was skipped, so never authorize it. + return authorizationUnavailable(); + } + const userId = + ctx.principal?.kind === "user" + ? ctx.principal.userId + : ctx.principal?.kind === "service" + ? ctx.principal.actor?.canonicalUserId + : null; + if (!userId) return null; + const requirement = { kind: "active-user" } as const; + try { + const authorization = await new AuthorizationService(ctx.db).getEffectiveAuthorization(userId); + ctx.authorization = authorization; + if (authorization.suspendedAt !== null) { + return authorizationDenial( + json({ error: "Forbidden", code: "active_user_required" }, 403), + evidence, + requirement, + "active_user_required", + "Forbidden" + ); + } + evidence.requirements.push(requirement); + return null; + } catch (cause) { + if (cause instanceof AuthorizationError) { + return authorizationDenial( + json({ error: "Forbidden", code: cause.code }, cause.status), + evidence, + requirement, + cause.code, + "Forbidden", + cause.permission + ); + } + return authorizationUnavailable(); + } +} + +/** + * Reject declarative permission requirements outside the service's static + * ceiling before the actor is enrolled, so a denied bot leaves no user, + * identity, or assignment behind. + */ +function enforceStaticServicePermissionCeiling( + policy: RouteAdmissionPolicy, + ctx: RequestContext, + evidence: AuthorizationEvidence +): AuthorizationFailure | null { + const principal = ctx.principal; + if (principal?.kind !== "service" || !principal.actor) return null; + if (policy.authorization.kind !== "active-user") return null; + + for (const requirement of policy.authorization.allOf) { + const permission = + requirement.kind === "permission" + ? requirement.permission + : requirement.kind === "scoped-permission" + ? SCOPED_PERMISSION_PAIRS[requirement.stem].own + : null; + if (permission && !serviceAllowsPermission(principal.service, permission)) { + return authorizationDenial( + json({ error: "Forbidden", code: "service_capability_required" }, 403), + evidence, + requirement, + "service_capability_required", + "Forbidden", + permission + ); + } + } + return null; +} + +/** + * Resolve the verified service actor to its canonical user exactly once, + * before any RBAC lookup, so the subject authorized is the subject attributed. + */ +async function finalizeServiceActor( + policy: RouteAdmissionPolicy, + request: Request, + ctx: RequestContext +): Promise { + if (!loadsCanonicalSubject(policy)) return null; + const principal = ctx.principal; + if (principal?.kind !== "service" || !principal.actor || principal.actor.canonicalUserId) { + return null; + } + + try { + const claims = policy.serviceActorClaims + ? await policy.serviceActorClaims(request.clone(), ctx) + : null; + const actor = principal.actor; + const user = await new UserStore(ctx.db).resolveOrCreateUser({ + provider: actor.provider, + providerUserId: actor.providerUserId, + displayName: claims?.displayName, + providerEmail: + actor.provider === "slack" || actor.provider === "linear" ? claims?.email : undefined, + avatarUrl: claims?.avatarUrl, + }); + ctx.principal = { + ...principal, + actor: { ...actor, canonicalUserId: user.id }, + }; + return null; + } catch (cause) { + logger.error("Failed to finalize verified service actor", { + event: "auth.service_actor_resolution_failed", + error: cause instanceof Error ? cause : String(cause), + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return authorizationUnavailable(); + } +} + +function authorizationUserId(ctx: RequestContext): string | null { + if (ctx.principal?.kind === "user") return ctx.principal.userId; + if (ctx.principal?.kind === "service") { + return ctx.principal.actor?.canonicalUserId ?? ctx.authorization?.userId ?? null; + } + return null; +} + +function actorlessGrantMatches( + grant: ActorlessServiceGrant, + service: string, + match: RegExpMatchArray +): boolean { + if (grant.service !== service) return false; + return Object.entries(grant.pathParams ?? {}).every(([name, expected]) => { + const value = match.groups?.[name]; + if (value === undefined) return false; + try { + return decodeURIComponent(value) === expected; + } catch { + return false; + } + }); +} + +function enforceServiceRouteAuthorization( + policy: RouteAdmissionPolicy, + match: RegExpMatchArray, + ctx: RequestContext, + evidence: AuthorizationEvidence +): AuthorizationFailure | null { + const principal = ctx.principal; + const authorization = policy.authorization; + const requirement = { kind: "service-capability" } as const; + const serviceCapabilityRequired = (): AuthorizationFailure => + authorizationDenial( + json({ error: "Forbidden", code: "service_capability_required" }, 403), + evidence, + requirement, + "service_capability_required", + "Forbidden" + ); + const serviceActorRequired = (): AuthorizationFailure => + authorizationDenial( + json({ error: "Forbidden", code: "service_actor_required" }, 403), + evidence, + requirement, + "service_actor_required", + "Forbidden" + ); + + if (authorization.kind === "service") { + if (principal?.kind !== "service") return serviceCapabilityRequired(); + if (!authorization.services.some((service) => service === principal.service)) { + return serviceCapabilityRequired(); + } + if (authorization.actor === "required" && !principal.actor) { + return serviceActorRequired(); + } + evidence.requirements.push(requirement); + return null; + } + if (principal?.kind !== "service") return null; + if (policy.authentication.kind === "web-service" && principal.service === "web") return null; + if ( + (authorization.kind !== "active-user" && authorization.kind !== "active-global") || + authorization.service.kind === "deny" + ) { + return serviceCapabilityRequired(); + } + if (principal.actor) { + evidence.requirements.push(requirement); + return null; + } + const granted = authorization.service.actorlessGrants?.some((grant) => + actorlessGrantMatches(grant, principal.service, match) + ); + if (granted) { + evidence.requirements.push({ kind: "actorless-service-grant", service: principal.service }); + return null; + } + return serviceActorRequired(); +} + +async function enforcePermissionRequirement( + requirement: Extract, + ctx: RequestContext, + evidence: AuthorizationEvidence +): Promise { + if ( + ctx.principal?.kind === "service" && + !serviceAllowsPermission(ctx.principal.service, requirement.permission) + ) { + return authorizationDenial( + json({ error: "Forbidden", code: "service_capability_required" }, 403), + evidence, + requirement, + "service_capability_required", + "Forbidden", + requirement.permission + ); + } + const userId = authorizationUserId(ctx); + if (!userId) { + evidence.requirements.push(requirement); + return null; + } + if (ctx.authorization?.permissions.includes(requirement.permission)) { + evidence.requirements.push(requirement); + evidence.effectivePermissions.push(requirement.permission); + return null; + } + return authorizationDenial( + json( + { error: "Forbidden", code: "permission_required", permission: requirement.permission }, + 403 + ), + evidence, + requirement, + "permission_required", + "Forbidden", + requirement.permission + ); +} + +async function enforceScopedPermissionRequirement( + requirement: Extract, + ctx: RequestContext, + evidence: AuthorizationEvidence +): Promise { + const pair = SCOPED_PERMISSION_PAIRS[requirement.stem]; + if ( + ctx.principal?.kind === "service" && + !serviceAllowsPermission(ctx.principal.service, pair.own) + ) { + return authorizationDenial( + json({ error: "Forbidden", code: "service_capability_required" }, 403), + evidence, + requirement, + "service_capability_required", + "Forbidden", + pair.own + ); + } + const userId = authorizationUserId(ctx); + if (!userId) { + evidence.requirements.push(requirement); + return null; + } + const scope = ctx.authorization + ? resolveScopedPermission(requirement.stem, ctx.authorization.permissions) + : null; + if (scope) { + evidence.requirements.push(requirement); + evidence.effectivePermissions.push(pair[scope]); + return null; + } + return authorizationDenial( + json({ error: "Forbidden", code: "permission_required", permission: pair.own }, 403), + evidence, + requirement, + "permission_required", + "Forbidden", + pair.own + ); +} + +async function enforceAutomationRequirement( + requirement: Extract, + match: RegExpMatchArray, + ctx: RequestContext, + evidence: AuthorizationEvidence +): Promise { + if (ctx.principal?.kind !== "user") { + // Ownership is defined for canonical human users only. Service policy + // normally rejects bots earlier; this keeps a future `requireAll` + // composition from skipping the ownership check. + return authorizationDenial( + json({ error: "Forbidden", code: "service_capability_required" }, 403), + evidence, + requirement, + "service_capability_required", + "Forbidden" + ); + } + const encodedAutomationId = match.groups?.[requirement.automationIdParam]; + if (!encodedAutomationId) return { response: json({ error: "Invalid automation route" }, 400) }; + let automationId: string; + try { + automationId = decodeURIComponent(encodedAutomationId); + } catch { + return { response: json({ error: "Invalid automation route" }, 400) }; + } + + try { + const authorization = ctx.authorization; + if (!authorization) throw new Error("Missing request authorization"); + const store = new AutomationStore(ctx.db); + const storedAutomation = await store.getById(automationId); + if (!storedAutomation) return { response: error("Automation not found", 404) }; + const automation = await store.resolveCanonicalOwner(storedAutomation); + + const permissionStem = `automations.${requirement.operation}` as const; + const pair = SCOPED_PERMISSION_PAIRS[permissionStem]; + const isOwner = automation.user_id === ctx.principal.userId; + const scope = resolveScopedPermission(permissionStem, authorization.permissions); + if (!scope || (scope === "own" && !isOwner)) { + return authorizationDenial( + json({ error: "Forbidden", code: "permission_required", permission: pair.own }, 403), + evidence, + requirement, + "permission_required", + "Forbidden", + pair.own + ); + } + + evidence.requirements.push(requirement); + evidence.effectivePermissions.push(pair[scope]); + ctx.automationAdmission = { automation }; + return null; + } catch { + return authorizationUnavailable(); + } +} + +function allowed( + policy: RouteAdmissionPolicy, + admission: AllowedAuthorizationDecision["admission"], + evidence: AuthorizationEvidence +): RouteAuthorizationResult { + return { + kind: "allowed", + decision: { + kind: "allowed", + admission, + auditAllowed: policy.authorization.auditAllowed, + ...evidence, + }, + }; +} + +/** + * Ordered trust transition for an authenticated request: principal kind, + * sandbox capability, service capability and ceiling, actor finalization, + * active canonical subject, then route permission and resource requirements. + */ +async function enforceRouteAuthorization( + policy: RouteAdmissionPolicy, + match: RegExpMatchArray, + request: Request, + ctx: RequestContext +): Promise { + const evidence = emptyEvidence(); + const principal = ctx.principal; + if (!principal) return allowed(policy, "user", evidence); + + const principalFailure = enforceRoutePrincipal(policy.authentication, principal, evidence); + if (principalFailure) return resultForFailure(principalFailure); + + if ( + principal.kind === "sandbox" && + policy.authentication.kind === "user-or-service-with-sandbox-fallback" + ) { + evidence.requirements.push({ kind: "sandbox-admission", sessionId: principal.sessionId }); + return allowed(policy, "sandbox", evidence); + } + + const serviceFailure = enforceServiceRouteAuthorization(policy, match, ctx, evidence); + if (serviceFailure) return resultForFailure(serviceFailure); + + const ceilingFailure = enforceStaticServicePermissionCeiling(policy, ctx, evidence); + if (ceilingFailure) return resultForFailure(ceilingFailure); + + const actorFailure = await finalizeServiceActor(policy, request, ctx); + if (actorFailure) return resultForFailure(actorFailure); + + const activeUserFailure = await enforceActiveUser(policy, ctx, evidence); + if (activeUserFailure) return resultForFailure(activeUserFailure); + + if (policy.authorization.kind === "active-user") { + for (const requirement of policy.authorization.allOf) { + let failure: AuthorizationFailure | null; + switch (requirement.kind) { + case "permission": + failure = await enforcePermissionRequirement(requirement, ctx, evidence); + break; + case "scoped-permission": + failure = await enforceScopedPermissionRequirement(requirement, ctx, evidence); + break; + case "automation": + failure = await enforceAutomationRequirement(requirement, match, ctx, evidence); + break; + } + if (failure) return resultForFailure(failure); + } + } + + const admission = + principal.kind === "service" ? "service" : principal.kind === "sandbox" ? "sandbox" : "user"; + return allowed(policy, admission, evidence); +} + +export async function admitRoute(input: { + request: Request; + env: Env; + policy: RouteAdmissionPolicy; + match: RegExpMatchArray; + pathname: string; + ctx: RequestContext; +}): Promise { + const { env, match, pathname, policy, ctx } = input; + let handlerRequest = input.request; + const authentication = policy.authentication; + + if (authentication.kind !== "public" && authentication.kind !== "handler-authenticated") { + let authError: Response | null; + const sandboxSessionId = + authentication.kind === "sandbox" || + authentication.kind === "user-or-service-with-sandbox-fallback" + ? authentication.getSessionId(match) + : null; + + if (authentication.kind === "sandbox") { + authError = sandboxSessionId + ? await verifySandboxAuthSafely(handlerRequest, env, sandboxSessionId, ctx) + : error("Unauthorized: Invalid session path", 401); + } else { + const authResult = await authenticate(handlerRequest, env, ctx, { + webService: + authentication.kind === "web-service" || authentication.kind === "service" + ? "service" + : "user", + }); + + if (isAuthError(authResult)) { + // A service-credential attempt is terminal; only a request with no + // recognized credential may still be a sandbox-token call on a + // sandbox-accepting route. + authError = error(authResult.reason, authResult.status); + if ( + authResult.failedScheme === "none" && + authentication.kind === "user-or-service-with-sandbox-fallback" && + sandboxSessionId + ) { + authError = await verifySandboxAuthSafely(handlerRequest, env, sandboxSessionId, ctx); + } + } else { + authError = null; + ctx.principal = authResult.principal; + ctx.authentication = authResult.authentication; + handlerRequest = authResult.request; + } + } + + if (authError) { + if (ctx.principal) { + logPrincipal(ctx.principal, ctx, pathname); + } + return denied(authError, { requestLog: ctx.principal ? "emit" : "skip" }); + } + + if (ctx.principal) { + logPrincipal(ctx.principal, ctx, pathname); + } + } + + const authorization = await enforceRouteAuthorization(policy, match, handlerRequest, ctx); + if (authorization.kind !== "allowed") { + return denied( + authorization.response, + authorization.kind === "denied" ? { decision: authorization.decision } : undefined + ); + } + + const providerCheck = enforceImplementedScmProvider(policy, pathname, env, ctx); + if (providerCheck) { + return denied(providerCheck, { requestLog: "skip" }); + } + + return { kind: "admitted", handlerRequest, decision: authorization.decision }; +} diff --git a/packages/control-plane/src/routing/route-dispatch.ts b/packages/control-plane/src/routing/route-dispatch.ts new file mode 100644 index 0000000000..bd4fd6ca18 --- /dev/null +++ b/packages/control-plane/src/routing/route-dispatch.ts @@ -0,0 +1,92 @@ +/** Execute one raw-path matched route through admission and response policy. */ + +import { + auditRouteAuthorizationDecision, + shouldAuditAllowedDecision, +} from "../authorization/request-audit"; +import type { RequestContext } from "../http/request-context"; +import { error, HttpError } from "../http/responses"; +import { createLogger } from "../logger"; +import type { Route } from "../routes/shared"; +import type { Env } from "../types"; +import { admitRoute } from "./route-admission"; +import { finalizeRouteResponse, logRequest } from "./request-lifecycle"; + +const logger = createLogger("router"); + +export async function dispatchMatchedRoute(input: { + request: Request; + env: Env; + route: Route; + match: RegExpMatchArray; + pathname: string; + context: RequestContext; + startedAt: number; +}): Promise { + const { env, match, pathname, route, context, startedAt } = input; + const method = input.request.method; + const admission = await admitRoute({ + request: input.request, + env, + policy: route, + match, + pathname, + ctx: context, + }); + + if (admission.kind === "denied") { + if (admission.decision) { + await auditRouteAuthorizationDecision({ + ctx: context, + method, + path: pathname, + response: admission.response, + decision: admission.decision, + }); + } + if (admission.requestLog === "emit") { + logRequest(admission.response, context, method, pathname, startedAt); + } + return finalizeRouteResponse(admission.response, route, context); + } + + const auditAllowedDecision = async (response: Response): Promise => { + if (!shouldAuditAllowedDecision(admission.decision)) return; + await auditRouteAuthorizationDecision({ + ctx: context, + method, + path: pathname, + response, + decision: admission.decision, + }); + }; + + let response: Response; + try { + response = await route.handler(admission.handlerRequest, env, match, context); + } catch (caught) { + if (caught instanceof HttpError) { + response = error(caught.message, caught.status); + } else { + logger.error("http.request", { + event: "http.request", + request_id: context.request_id, + trace_id: context.trace_id, + http_method: method, + http_path: pathname, + http_status: 500, + duration_ms: Date.now() - startedAt, + outcome: "error", + error: caught instanceof Error ? caught : String(caught), + ...context.metrics.summarize(), + }); + response = error("Internal server error", 500); + await auditAllowedDecision(response); + return finalizeRouteResponse(response, route, context); + } + } + + logRequest(response, context, method, pathname, startedAt); + await auditAllowedDecision(response); + return finalizeRouteResponse(response, route, context); +} diff --git a/packages/control-plane/src/webhooks/automation-event.ts b/packages/control-plane/src/webhooks/automation-event.ts index 558361b579..1d9e6094f7 100644 --- a/packages/control-plane/src/webhooks/automation-event.ts +++ b/packages/control-plane/src/webhooks/automation-event.ts @@ -21,7 +21,6 @@ import { error, GITHUB_SERVICE_ROUTE, json, - parsePattern, serviceAuthorized, } from "../routes/shared"; import type { Env } from "../types"; @@ -154,7 +153,7 @@ export function createAutomationEventRoute(opts: { return defineRoute(GITHUB_SERVICE_ROUTE, { method: "POST", - pattern: parsePattern(opts.path), + path: opts.path, authorization: serviceAuthorized("slack-bot"), handler, }); diff --git a/packages/control-plane/src/webhooks/automation-webhook.ts b/packages/control-plane/src/webhooks/automation-webhook.ts index ebcd1ca6f3..fc8a3bdefc 100644 --- a/packages/control-plane/src/webhooks/automation-webhook.ts +++ b/packages/control-plane/src/webhooks/automation-webhook.ts @@ -11,7 +11,6 @@ import { error, json, NO_AUTHORIZATION, - parsePattern, SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE, } from "../routes/shared"; import type { Env } from "../types"; @@ -90,7 +89,7 @@ async function handleAutomationWebhook( export const automationWebhookRoute: Route = defineRoute(SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE, { method: "POST", - pattern: parsePattern("/webhooks/automation/:id"), + path: "/webhooks/automation/:id", authorization: NO_AUTHORIZATION, handler: handleAutomationWebhook, }); diff --git a/packages/control-plane/src/webhooks/github.ts b/packages/control-plane/src/webhooks/github.ts index 96987b721d..38a0ec8220 100644 --- a/packages/control-plane/src/webhooks/github.ts +++ b/packages/control-plane/src/webhooks/github.ts @@ -14,13 +14,7 @@ import { SessionInternalPaths } from "../session/contracts"; import { createSessionRuntimeClient } from "../session/runtime-client"; import type { Env } from "../types"; import type { RequestContext, Route } from "../routes/shared"; -import { - defineRoute, - error, - GITHUB_SERVICE_ROUTE, - parsePattern, - serviceAuthorized, -} from "../routes/shared"; +import { defineRoute, error, GITHUB_SERVICE_ROUTE, serviceAuthorized } from "../routes/shared"; import { forwardAutomationEventToScheduler, logAutomationEventRejection, @@ -128,7 +122,7 @@ async function handleGitHubAutomationEvent( export const githubAutomationEventRoute: Route = defineRoute(GITHUB_SERVICE_ROUTE, { method: "POST", - pattern: parsePattern("/internal/github-event"), + path: "/internal/github-event", authorization: serviceAuthorized("github-bot"), handler: handleGitHubAutomationEvent, }); diff --git a/packages/control-plane/src/webhooks/sentry.ts b/packages/control-plane/src/webhooks/sentry.ts index 484ff1a8d6..47b4e2f42e 100644 --- a/packages/control-plane/src/webhooks/sentry.ts +++ b/packages/control-plane/src/webhooks/sentry.ts @@ -13,7 +13,6 @@ import { error, json, NO_AUTHORIZATION, - parsePattern, SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE, } from "../routes/shared"; import type { Env } from "../types"; @@ -122,7 +121,7 @@ async function handleSentryWebhook( export const sentryWebhookRoute: Route = defineRoute(SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE, { method: "POST", - pattern: parsePattern("/webhooks/sentry/:id"), + path: "/webhooks/sentry/:id", authorization: NO_AUTHORIZATION, handler: handleSentryWebhook, }); diff --git a/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap b/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap new file mode 100644 index 0000000000..f4311a7e8a --- /dev/null +++ b/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap @@ -0,0 +1,177 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Hono route catalog conformance > dispatches every frozen method/path/policy entry with raw captures 1`] = ` +[ + "{"identity":"GET /health","pathname":"/health","groups":{},"pattern":"^\\\\/health$","authentication":"public","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /api/auth/sign-in/social","pathname":"/api/auth/sign-in/social","groups":{},"pattern":"^\\\\/api\\\\/auth\\\\/sign-in\\\\/social$","authentication":"web-service","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /api/auth/callback/github","pathname":"/api/auth/callback/github","groups":{},"pattern":"^\\\\/api\\\\/auth\\\\/callback\\\\/github$","authentication":"web-service","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /api/auth/callback/google","pathname":"/api/auth/callback/google","groups":{},"pattern":"^\\\\/api\\\\/auth\\\\/callback\\\\/google$","authentication":"web-service","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /api/auth/get-session","pathname":"/api/auth/get-session","groups":{},"pattern":"^\\\\/api\\\\/auth\\\\/get-session$","authentication":"web-service","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /api/auth/sign-out","pathname":"/api/auth/sign-out","groups":{},"pattern":"^\\\\/api\\\\/auth\\\\/sign-out$","authentication":"web-service","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /api/auth/error","pathname":"/api/auth/error","groups":{},"pattern":"^\\\\/api\\\\/auth\\\\/error$","authentication":"web-service","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /internal/auth/sign-in-providers","pathname":"/internal/auth/sign-in-providers","groups":{},"pattern":"^\\\\/internal\\\\/auth\\\\/sign-in-providers$","authentication":"web-service","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions","pathname":"/sessions","groups":{},"pattern":"^\\\\/sessions$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.create"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":true}", + "{"identity":"GET /sessions","pathname":"/sessions","groups":{},"pattern":"^\\\\/sessions$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/inbox","pathname":"/sessions/inbox","groups":{},"pattern":"^\\\\/sessions\\\\/inbox$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /sessions/:id/read-state","pathname":"/sessions/fixture-11-id%2Fraw/read-state","groups":{"id":"fixture-11-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/read-state$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /sessions/:id","pathname":"/sessions/fixture-12-id%2Fraw","groups":{"id":"fixture-12-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.delete"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/sandbox-access","pathname":"/sessions/fixture-13-id%2Fraw/sandbox-access","groups":{"id":"fixture-13-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/sandbox-access$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.sandbox_access"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id","pathname":"/sessions/fixture-14-id%2Fraw","groups":{"id":"fixture-14-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/stop","pathname":"/sessions/fixture-15-id%2Fraw/stop","groups":{"id":"fixture-15-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/stop$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor","actorlessGrants":[{"service":"linear-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/sandbox-error","pathname":"/sessions/fixture-16-id%2Fraw/sandbox-error","groups":{"id":"fixture-16-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/sandbox-error$","authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/events","pathname":"/sessions/fixture-17-id%2Fraw/events","groups":{"id":"fixture-17-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/events$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/artifacts","pathname":"/sessions/fixture-18-id%2Fraw/artifacts","groups":{"id":"fixture-18-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/artifacts$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/participants","pathname":"/sessions/fixture-19-id%2Fraw/participants","groups":{"id":"fixture-19-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/participants$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/participant-profiles","pathname":"/sessions/fixture-20-id%2Fraw/participant-profiles","groups":{"id":"fixture-20-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/participant-profiles$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/messages","pathname":"/sessions/fixture-21-id%2Fraw/messages","groups":{"id":"fixture-21-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/messages$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/pr","pathname":"/sessions/fixture-22-id%2Fraw/pr","groups":{"id":"fixture-22-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/pr$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/openai-token-refresh","pathname":"/sessions/fixture-23-id%2Fraw/openai-token-refresh","groups":{"id":"fixture-23-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/openai-token-refresh$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/xai-token-refresh","pathname":"/sessions/fixture-24-id%2Fraw/xai-token-refresh","groups":{"id":"fixture-24-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/xai-token-refresh$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/scm-credentials","pathname":"/sessions/fixture-25-id%2Fraw/scm-credentials","groups":{"id":"fixture-25-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/scm-credentials$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":["github","gitlab"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/tunnel-urls","pathname":"/sessions/fixture-26-id%2Fraw/tunnel-urls","groups":{"id":"fixture-26-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/tunnel-urls$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.sandbox_access"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /sessions/:id/title","pathname":"/sessions/fixture-27-id%2Fraw/title","groups":{"id":"fixture-27-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/title$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/archive","pathname":"/sessions/fixture-28-id%2Fraw/archive","groups":{"id":"fixture-28-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/archive$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/unarchive","pathname":"/sessions/fixture-29-id%2Fraw/unarchive","groups":{"id":"fixture-29-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/unarchive$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/ws-token","pathname":"/sessions/fixture-30-id%2Fraw/ws-token","groups":{"id":"fixture-30-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/ws-token$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/prompt","pathname":"/sessions/fixture-31-id%2Fraw/prompt","groups":{"id":"fixture-31-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/prompt$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/pull-requests/refresh","pathname":"/sessions/fixture-32-id%2Fraw/pull-requests/refresh","groups":{"id":"fixture-32-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/pull-requests\\\\/refresh$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/media","pathname":"/sessions/fixture-33-id%2Fraw/media","groups":{"id":"fixture-33-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/media$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/media/:artifactId","pathname":"/sessions/fixture-34-id%2Fraw/media/fixture-34-artifactId%2Fraw","groups":{"id":"fixture-34-id%2Fraw","artifactId":"fixture-34-artifactId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/media\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/attachments","pathname":"/sessions/fixture-35-id%2Fraw/attachments","groups":{"id":"fixture-35-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/attachments$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/attachments/:attachmentId","pathname":"/sessions/fixture-36-id%2Fraw/attachments/fixture-36-attachmentId%2Fraw","groups":{"id":"fixture-36-id%2Fraw","attachmentId":"fixture-36-attachmentId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/attachments\\\\/(?[^/]+)$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/diff","pathname":"/sessions/fixture-37-id%2Fraw/diff","groups":{"id":"fixture-37-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /sessions/:id/diff","pathname":"/sessions/fixture-38-id%2Fraw/diff","groups":{"id":"fixture-38-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/diff/failure","pathname":"/sessions/fixture-39-id%2Fraw/diff/failure","groups":{"id":"fixture-39-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff\\\\/failure$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/diff/:revisionId/files/:fileId","pathname":"/sessions/fixture-40-id%2Fraw/diff/fixture-40-revisionId%2Fraw/files/fixture-40-fileId%2Fraw","groups":{"id":"fixture-40-id%2Fraw","revisionId":"fixture-40-revisionId%2Fraw","fileId":"fixture-40-fileId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff\\\\/(?[^/]+)\\\\/files\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/diff/retry","pathname":"/sessions/fixture-41-id%2Fraw/diff/retry","groups":{"id":"fixture-41-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/diff\\\\/retry$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/skills","pathname":"/sessions/fixture-42-id%2Fraw/skills","groups":{"id":"fixture-42-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/skills$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/sandbox-skills","pathname":"/sessions/fixture-43-id%2Fraw/sandbox-skills","groups":{"id":"fixture-43-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/sandbox-skills$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children","pathname":"/sessions/fixture-44-id%2Fraw/children","groups":{"id":"fixture-44-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.create"},{"kind":"permission","permission":"sessions.collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/children","pathname":"/sessions/fixture-45-id%2Fraw/children","groups":{"id":"fixture-45-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/children/:childId","pathname":"/sessions/fixture-46-id%2Fraw/children/fixture-46-childId%2Fraw","groups":{"id":"fixture-46-id%2Fraw","childId":"fixture-46-childId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children\\\\/(?[^/]+)$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children/:childId/cancel","pathname":"/sessions/fixture-47-id%2Fraw/children/fixture-47-childId%2Fraw/cancel","groups":{"id":"fixture-47-id%2Fraw","childId":"fixture-47-childId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children\\\\/(?[^/]+)\\\\/cancel$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.lifecycle"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children/:childId/prompt","pathname":"/sessions/fixture-48-id%2Fraw/children/fixture-48-childId%2Fraw/prompt","groups":{"id":"fixture-48-id%2Fraw","childId":"fixture-48-childId%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/children\\\\/(?[^/]+)\\\\/prompt$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/slack-notify","pathname":"/sessions/fixture-49-id%2Fraw/slack-notify","groups":{"id":"fixture-49-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/slack-notify$","authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.collaborate"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos","pathname":"/repos","groups":{},"pattern":"^\\\\/repos$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /repos/:owner/:name/metadata","pathname":"/repos/fixture-51-owner%2Fraw/fixture-51-name%2Fraw/metadata","groups":{"owner":"fixture-51-owner%2Fraw","name":"fixture-51-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/metadata$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/metadata","pathname":"/repos/fixture-52-owner%2Fraw/fixture-52-name%2Fraw/metadata","groups":{"owner":"fixture-52-owner%2Fraw","name":"fixture-52-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/metadata$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/branches","pathname":"/repos/fixture-53-owner%2Fraw/fixture-53-name%2Fraw/branches","groups":{"owner":"fixture-53-owner%2Fraw","name":"fixture-53-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/branches$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /repos/:owner/:name/secrets","pathname":"/repos/fixture-54-owner%2Fraw/fixture-54-name%2Fraw/secrets","groups":{"owner":"fixture-54-owner%2Fraw","name":"fixture-54-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/secrets","pathname":"/repos/fixture-55-owner%2Fraw/fixture-55-name%2Fraw/secrets","groups":{"owner":"fixture-55-owner%2Fraw","name":"fixture-55-name%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /repos/:owner/:name/secrets/:key","pathname":"/repos/fixture-56-owner%2Fraw/fixture-56-name%2Fraw/secrets/fixture-56-key%2Fraw","groups":{"owner":"fixture-56-owner%2Fraw","name":"fixture-56-name%2Fraw","key":"fixture-56-key%2Fraw"},"pattern":"^\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)\\\\/secrets\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /secrets","pathname":"/secrets","groups":{},"pattern":"^\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /secrets","pathname":"/secrets","groups":{},"pattern":"^\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /secrets/:key","pathname":"/secrets/fixture-59-key%2Fraw","groups":{"key":"fixture-59-key%2Fraw"},"pattern":"^\\\\/secrets\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /environments","pathname":"/environments","groups":{},"pattern":"^\\\\/environments$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /environments","pathname":"/environments","groups":{},"pattern":"^\\\\/environments$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /environments/:id","pathname":"/environments/fixture-62-id%2Fraw","groups":{"id":"fixture-62-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /environments/:id","pathname":"/environments/fixture-63-id%2Fraw","groups":{"id":"fixture-63-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /environments/:id","pathname":"/environments/fixture-64-id%2Fraw","groups":{"id":"fixture-64-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /environments/:id/secrets","pathname":"/environments/fixture-65-id%2Fraw/secrets","groups":{"id":"fixture-65-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /environments/:id/secrets","pathname":"/environments/fixture-66-id%2Fraw/secrets","groups":{"id":"fixture-66-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)\\\\/secrets$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /environments/:id/secrets/import","pathname":"/environments/fixture-67-id%2Fraw/secrets/import","groups":{"id":"fixture-67-id%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)\\\\/secrets\\\\/import$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /environments/:id/secrets/:key","pathname":"/environments/fixture-68-id%2Fraw/secrets/fixture-68-key%2Fraw","groups":{"id":"fixture-68-id%2Fraw","key":"fixture-68-key%2Fraw"},"pattern":"^\\\\/environments\\\\/(?[^/]+)\\\\/secrets\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /image-builds/build-complete","pathname":"/image-builds/build-complete","groups":{},"pattern":"^\\\\/image-builds\\\\/build-complete$","authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /image-builds/build-failed","pathname":"/image-builds/build-failed","groups":{},"pattern":"^\\\\/image-builds\\\\/build-failed$","authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /image-builds/trigger/environment/:id","pathname":"/image-builds/trigger/environment/fixture-71-id%2Fraw","groups":{"id":"fixture-71-id%2Fraw"},"pattern":"^\\\\/image-builds\\\\/trigger\\\\/environment\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /image-builds/trigger/repo/:owner/:name","pathname":"/image-builds/trigger/repo/fixture-72-owner%2Fraw/fixture-72-name%2Fraw","groups":{"owner":"fixture-72-owner%2Fraw","name":"fixture-72-name%2Fraw"},"pattern":"^\\\\/image-builds\\\\/trigger\\\\/repo\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /image-builds/toggle/repo/:owner/:name","pathname":"/image-builds/toggle/repo/fixture-73-owner%2Fraw/fixture-73-name%2Fraw","groups":{"owner":"fixture-73-owner%2Fraw","name":"fixture-73-name%2Fraw"},"pattern":"^\\\\/image-builds\\\\/toggle\\\\/repo\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /image-builds/status","pathname":"/image-builds/status","groups":{},"pattern":"^\\\\/image-builds\\\\/status$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /image-builds/enabled","pathname":"/image-builds/enabled","groups":{},"pattern":"^\\\\/image-builds\\\\/enabled$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /image-builds/enabled-repos","pathname":"/image-builds/enabled-repos","groups":{},"pattern":"^\\\\/image-builds\\\\/enabled-repos$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /model-preferences","pathname":"/model-preferences","groups":{},"pattern":"^\\\\/model-preferences$","authentication":"user-or-service","authorization":{"kind":"active-global","service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /model-preferences","pathname":"/model-preferences","groups":{},"pattern":"^\\\\/model-preferences$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"models.preferences.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /model-provider-accounts/legacy-credentials","pathname":"/model-provider-accounts/legacy-credentials","groups":{},"pattern":"^\\\\/model-provider-accounts\\\\/legacy-credentials$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /model-provider-accounts","pathname":"/model-provider-accounts","groups":{},"pattern":"^\\\\/model-provider-accounts$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts","pathname":"/model-provider-accounts","groups":{},"pattern":"^\\\\/model-provider-accounts$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/device-authorizations","pathname":"/model-provider-accounts/fixture-82-provider%2Fraw/device-authorizations","groups":{"provider":"fixture-82-provider%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/device-authorizations$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/device-authorizations/:id/poll","pathname":"/model-provider-accounts/fixture-83-provider%2Fraw/device-authorizations/fixture-83-id%2Fraw/poll","groups":{"provider":"fixture-83-provider%2Fraw","id":"fixture-83-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/device-authorizations\\\\/(?[^/]+)\\\\/poll$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-accounts/:provider/device-authorizations/:id","pathname":"/model-provider-accounts/fixture-84-provider%2Fraw/device-authorizations/fixture-84-id%2Fraw","groups":{"provider":"fixture-84-provider%2Fraw","id":"fixture-84-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/device-authorizations\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-85-id%2Fraw","groups":{"id":"fixture-85-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PATCH /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-86-id%2Fraw","groups":{"id":"fixture-86-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/verify","pathname":"/model-provider-accounts/fixture-87-id%2Fraw/verify","groups":{"id":"fixture-87-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/verify$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/disable","pathname":"/model-provider-accounts/fixture-88-id%2Fraw/disable","groups":{"id":"fixture-88-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/disable$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/enable","pathname":"/model-provider-accounts/fixture-89-id%2Fraw/enable","groups":{"id":"fixture-89-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/enable$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/reconnect","pathname":"/model-provider-accounts/fixture-90-id%2Fraw/reconnect","groups":{"id":"fixture-90-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)\\\\/reconnect$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-91-id%2Fraw","groups":{"id":"fixture-91-id%2Fraw"},"pattern":"^\\\\/model-provider-accounts\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /model-provider-account-defaults","pathname":"/model-provider-account-defaults","groups":{},"pattern":"^\\\\/model-provider-account-defaults$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-93-provider%2Fraw","groups":{"provider":"fixture-93-provider%2Fraw"},"pattern":"^\\\\/model-provider-account-defaults\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-94-provider%2Fraw","groups":{"provider":"fixture-94-provider%2Fraw"},"pattern":"^\\\\/model-provider-account-defaults\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/provider-auth/:provider/access-token","pathname":"/sessions/fixture-95-id%2Fraw/provider-auth/fixture-95-provider%2Fraw/access-token","groups":{"id":"fixture-95-id%2Fraw","provider":"fixture-95-provider%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/provider-auth\\\\/(?[^/]+)\\\\/access-token$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id","pathname":"/integration-settings/fixture-96-id%2Fraw","groups":{"id":"fixture-96-id%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot","pathParams":{"id":"slack"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id","pathname":"/integration-settings/fixture-97-id%2Fraw","groups":{"id":"fixture-97-id%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id","pathname":"/integration-settings/fixture-98-id%2Fraw","groups":{"id":"fixture-98-id%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/repos","pathname":"/integration-settings/fixture-99-id%2Fraw/repos","groups":{"id":"fixture-99-id%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/repos$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-100-id%2Fraw/repos/fixture-100-owner%2Fraw/fixture-100-name%2Fraw","groups":{"id":"fixture-100-id%2Fraw","owner":"fixture-100-owner%2Fraw","name":"fixture-100-name%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-101-id%2Fraw/repos/fixture-101-owner%2Fraw/fixture-101-name%2Fraw","groups":{"id":"fixture-101-id%2Fraw","owner":"fixture-101-owner%2Fraw","name":"fixture-101-name%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-102-id%2Fraw/repos/fixture-102-owner%2Fraw/fixture-102-name%2Fraw","groups":{"id":"fixture-102-id%2Fraw","owner":"fixture-102-owner%2Fraw","name":"fixture-102-name%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-103-id%2Fraw/environments/fixture-103-environmentId%2Fraw","groups":{"id":"fixture-103-id%2Fraw","environmentId":"fixture-103-environmentId%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-104-id%2Fraw/environments/fixture-104-environmentId%2Fraw","groups":{"id":"fixture-104-id%2Fraw","environmentId":"fixture-104-environmentId%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-105-id%2Fraw/environments/fixture-105-environmentId%2Fraw","groups":{"id":"fixture-105-id%2Fraw","environmentId":"fixture-105-environmentId%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/environments\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/resolved/:owner/:name","pathname":"/integration-settings/fixture-106-id%2Fraw/resolved/fixture-106-owner%2Fraw/fixture-106-name%2Fraw","groups":{"id":"fixture-106-id%2Fraw","owner":"fixture-106-owner%2Fraw","name":"fixture-106-name%2Fraw"},"pattern":"^\\\\/integration-settings\\\\/(?[^/]+)\\\\/resolved\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot","pathParams":{"id":"github"}},{"service":"linear-bot","pathParams":{"id":"linear"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /commit-signing","pathname":"/commit-signing","groups":{},"pattern":"^\\\\/commit-signing$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /commit-signing","pathname":"/commit-signing","groups":{},"pattern":"^\\\\/commit-signing$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"commit_signing.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /commit-signing","pathname":"/commit-signing","groups":{},"pattern":"^\\\\/commit-signing$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"commit_signing.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/commit-signing","pathname":"/sessions/fixture-110-id%2Fraw/commit-signing","groups":{"id":"fixture-110-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/commit-signing$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/commit-signing","pathname":"/sessions/fixture-111-id%2Fraw/commit-signing","groups":{"id":"fixture-111-id%2Fraw"},"pattern":"^\\\\/sessions\\\\/(?[^/]+)\\\\/commit-signing$","authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /scm-settings","pathname":"/scm-settings","groups":{},"pattern":"^\\\\/scm-settings$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /scm-settings","pathname":"/scm-settings","groups":{},"pattern":"^\\\\/scm-settings$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /scm-settings","pathname":"/scm-settings","groups":{},"pattern":"^\\\\/scm-settings$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /scm-settings/repos","pathname":"/scm-settings/repos","groups":{},"pattern":"^\\\\/scm-settings\\\\/repos$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-116-owner%2Fraw/fixture-116-name%2Fraw","groups":{"owner":"fixture-116-owner%2Fraw","name":"fixture-116-name%2Fraw"},"pattern":"^\\\\/scm-settings\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-117-owner%2Fraw/fixture-117-name%2Fraw","groups":{"owner":"fixture-117-owner%2Fraw","name":"fixture-117-name%2Fraw"},"pattern":"^\\\\/scm-settings\\\\/repos\\\\/(?[^/]+)\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/slack/watched-channels","pathname":"/integration-settings/slack/watched-channels","groups":{},"pattern":"^\\\\/integration-settings\\\\/slack\\\\/watched-channels$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/slack/channels","pathname":"/integration-settings/slack/channels","groups":{},"pattern":"^\\\\/integration-settings\\\\/slack\\\\/channels$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations","pathname":"/automations","groups":{},"pattern":"^\\\\/automations$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations","pathname":"/automations","groups":{},"pattern":"^\\\\/automations$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.create"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id","pathname":"/automations/fixture-122-id%2Fraw","groups":{"id":"fixture-122-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /automations/:id","pathname":"/automations/fixture-123-id%2Fraw","groups":{"id":"fixture-123-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /automations/:id","pathname":"/automations/fixture-124-id%2Fraw","groups":{"id":"fixture-124-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/pause","pathname":"/automations/fixture-125-id%2Fraw/pause","groups":{"id":"fixture-125-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/pause$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/resume","pathname":"/automations/fixture-126-id%2Fraw/resume","groups":{"id":"fixture-126-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/resume$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/trigger","pathname":"/automations/fixture-127-id%2Fraw/trigger","groups":{"id":"fixture-127-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/trigger$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"trigger","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id/invocations","pathname":"/automations/fixture-128-id%2Fraw/invocations","groups":{"id":"fixture-128-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/invocations$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id/runs/:runId","pathname":"/automations/fixture-129-id%2Fraw/runs/fixture-129-runId%2Fraw","groups":{"id":"fixture-129-id%2Fraw","runId":"fixture-129-runId%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/runs\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/regenerate-key","pathname":"/automations/fixture-130-id%2Fraw/regenerate-key","groups":{"id":"fixture-130-id%2Fraw"},"pattern":"^\\\\/automations\\\\/(?[^/]+)\\\\/regenerate-key$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /mcp-servers","pathname":"/mcp-servers","groups":{},"pattern":"^\\\\/mcp-servers$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /mcp-servers","pathname":"/mcp-servers","groups":{},"pattern":"^\\\\/mcp-servers$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /mcp-servers/:id","pathname":"/mcp-servers/fixture-133-id%2Fraw","groups":{"id":"fixture-133-id%2Fraw"},"pattern":"^\\\\/mcp-servers\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /mcp-servers/:id","pathname":"/mcp-servers/fixture-134-id%2Fraw","groups":{"id":"fixture-134-id%2Fraw"},"pattern":"^\\\\/mcp-servers\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /mcp-servers/:id","pathname":"/mcp-servers/fixture-135-id%2Fraw","groups":{"id":"fixture-135-id%2Fraw"},"pattern":"^\\\\/mcp-servers\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /analytics/dashboard","pathname":"/analytics/dashboard","groups":{},"pattern":"^\\\\/analytics\\\\/dashboard$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /analytics/summary","pathname":"/analytics/summary","groups":{},"pattern":"^\\\\/analytics\\\\/summary$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /analytics/timeseries","pathname":"/analytics/timeseries","groups":{},"pattern":"^\\\\/analytics\\\\/timeseries$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /analytics/breakdown","pathname":"/analytics/breakdown","groups":{},"pattern":"^\\\\/analytics\\\\/breakdown$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /analytics/pull-requests","pathname":"/analytics/pull-requests","groups":{},"pattern":"^\\\\/analytics\\\\/pull-requests$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /audit-events","pathname":"/audit-events","groups":{},"pattern":"^\\\\/audit-events$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.audit.read"}],"auditAllowed":false,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /autofix/activity","pathname":"/autofix/activity","groups":{},"pattern":"^\\\\/autofix\\\\/activity$","authentication":"web-service","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /skills","pathname":"/skills","groups":{},"pattern":"^\\\\/skills$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/preview","pathname":"/skills/preview","groups":{},"pattern":"^\\\\/skills\\\\/preview$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/resolve-preview","pathname":"/skills/resolve-preview","groups":{},"pattern":"^\\\\/skills\\\\/resolve-preview$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /skills/:id","pathname":"/skills/fixture-146-id%2Fraw","groups":{"id":"fixture-146-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)$","authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills","pathname":"/skills","groups":{},"pattern":"^\\\\/skills$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/import/preview","pathname":"/skills/import/preview","groups":{},"pattern":"^\\\\/skills\\\\/import\\\\/preview$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/import","pathname":"/skills/import","groups":{},"pattern":"^\\\\/skills\\\\/import$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/:id/reimport/preview","pathname":"/skills/fixture-150-id%2Fraw/reimport/preview","groups":{"id":"fixture-150-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)\\\\/reimport\\\\/preview$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/:id/reimport","pathname":"/skills/fixture-151-id%2Fraw/reimport","groups":{"id":"fixture-151-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)\\\\/reimport$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /skills/:id","pathname":"/skills/fixture-152-id%2Fraw","groups":{"id":"fixture-152-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /skills/:id","pathname":"/skills/fixture-153-id%2Fraw","groups":{"id":"fixture-153-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /skills/:id","pathname":"/skills/fixture-154-id%2Fraw","groups":{"id":"fixture-154-id%2Fraw"},"pattern":"^\\\\/skills\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /skill-profiles","pathname":"/skill-profiles","groups":{},"pattern":"^\\\\/skill-profiles$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skill-profiles","pathname":"/skill-profiles","groups":{},"pattern":"^\\\\/skill-profiles$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /skill-profiles/:id","pathname":"/skill-profiles/fixture-157-id%2Fraw","groups":{"id":"fixture-157-id%2Fraw"},"pattern":"^\\\\/skill-profiles\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /skill-profiles/:id","pathname":"/skill-profiles/fixture-158-id%2Fraw","groups":{"id":"fixture-158-id%2Fraw"},"pattern":"^\\\\/skill-profiles\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /keyboard-shortcuts","pathname":"/keyboard-shortcuts","groups":{},"pattern":"^\\\\/keyboard-shortcuts$","authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /keyboard-shortcuts","pathname":"/keyboard-shortcuts","groups":{},"pattern":"^\\\\/keyboard-shortcuts$","authentication":"user","authorization":{"kind":"active-self","auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /me/authorization","pathname":"/me/authorization","groups":{},"pattern":"^\\\\/me\\\\/authorization$","authentication":"user","authorization":{"kind":"authenticated","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /roles","pathname":"/roles","groups":{},"pattern":"^\\\\/roles$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /roles/:id","pathname":"/roles/fixture-163-id%2Fraw","groups":{"id":"fixture-163-id%2Fraw"},"pattern":"^\\\\/roles\\\\/(?[^/]+)$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /members","pathname":"/members","groups":{},"pattern":"^\\\\/members$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /members/:id/role","pathname":"/members/fixture-165-id%2Fraw/role","groups":{"id":"fixture-165-id%2Fraw"},"pattern":"^\\\\/members\\\\/(?[^/]+)\\\\/role$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /members/:id/status","pathname":"/members/fixture-166-id%2Fraw/status","groups":{"id":"fixture-166-id%2Fraw"},"pattern":"^\\\\/members\\\\/(?[^/]+)\\\\/status$","authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /webhooks/sentry/:id","pathname":"/webhooks/sentry/fixture-167-id%2Fraw","groups":{"id":"fixture-167-id%2Fraw"},"pattern":"^\\\\/webhooks\\\\/sentry\\\\/(?[^/]+)$","authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /webhooks/automation/:id","pathname":"/webhooks/automation/fixture-168-id%2Fraw","groups":{"id":"fixture-168-id%2Fraw"},"pattern":"^\\\\/webhooks\\\\/automation\\\\/(?[^/]+)$","authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /internal/github-event","pathname":"/internal/github-event","groups":{},"pattern":"^\\\\/internal\\\\/github-event$","authentication":"service","authorization":{"kind":"service","services":["github-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /internal/slack-event","pathname":"/internal/slack-event","groups":{},"pattern":"^\\\\/internal\\\\/slack-event$","authentication":"service","authorization":{"kind":"service","services":["slack-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", +] +`; diff --git a/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap b/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap new file mode 100644 index 0000000000..ebe436040a --- /dev/null +++ b/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap @@ -0,0 +1,368 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`route admission matrix > admits a session-bound sandbox token on every sandbox-accepting route 1`] = ` +[ + "POST /sessions/:id/pr sandbox=400 wrong-token=401", + "POST /sessions/:id/openai-token-refresh sandbox=404 wrong-token=401", + "POST /sessions/:id/xai-token-refresh sandbox=404 wrong-token=401", + "POST /sessions/:id/scm-credentials sandbox=500 wrong-token=401", + "GET /sessions/:id/tunnel-urls sandbox=200 wrong-token=401", + "POST /sessions/:id/media sandbox=400 wrong-token=401", + "GET /sessions/:id/attachments/:attachmentId sandbox=404 wrong-token=401", + "PUT /sessions/:id/diff sandbox=400 wrong-token=401", + "POST /sessions/:id/diff/failure sandbox=400 wrong-token=401", + "GET /sessions/:id/sandbox-skills sandbox=200 wrong-token=401", + "POST /sessions/:id/children sandbox=400 wrong-token=401", + "GET /sessions/:id/children sandbox=200 wrong-token=401", + "GET /sessions/:id/children/:childId sandbox=404 wrong-token=401", + "POST /sessions/:id/children/:childId/cancel sandbox=404 wrong-token=401", + "POST /sessions/:id/children/:childId/prompt sandbox=400 wrong-token=401", + "POST /sessions/:id/slack-notify sandbox=400 wrong-token=401", + "POST /sessions/:id/provider-auth/:provider/access-token sandbox=404 wrong-token=401", + "GET /sessions/:id/commit-signing sandbox=200 wrong-token=401", + "POST /sessions/:id/commit-signing sandbox=400 wrong-token=401", +] +`; + +exports[`route admission matrix > admits only the named bot on every exact-service route 1`] = ` +[ + "POST /internal/github-event github-bot=400 slack-bot=403 web=403", + "POST /internal/slack-event slack-bot=400 github-bot=403 web=403", +] +`; + +exports[`route admission matrix > admits the workspace owner through every browser-reachable route 1`] = ` +[ + "POST /api/auth/sign-in/social owner=403", + "GET /api/auth/callback/github owner=401", + "GET /api/auth/callback/google owner=401", + "GET /api/auth/get-session owner=200", + "POST /api/auth/sign-out owner=403", + "GET /api/auth/error owner=200", + "GET /internal/auth/sign-in-providers owner=200", + "POST /sessions owner=201", + "GET /sessions owner=200", + "GET /sessions/inbox owner=200", + "PATCH /sessions/:id/read-state owner=400", + "DELETE /sessions/:id owner=200", + "GET /sessions/:id/sandbox-access owner=409", + "GET /sessions/:id owner=200", + "POST /sessions/:id/stop owner=200", + "GET /sessions/:id/events owner=200", + "GET /sessions/:id/artifacts owner=200", + "GET /sessions/:id/participants owner=200", + "GET /sessions/:id/participant-profiles owner=200", + "GET /sessions/:id/messages owner=200", + "POST /sessions/:id/pr owner=400", + "GET /sessions/:id/tunnel-urls owner=200", + "PATCH /sessions/:id/title owner=400", + "POST /sessions/:id/archive owner=200", + "POST /sessions/:id/unarchive owner=409", + "POST /sessions/:id/ws-token owner=200", + "POST /sessions/:id/prompt owner=400", + "POST /sessions/:id/pull-requests/refresh owner=202", + "POST /sessions/:id/media owner=400", + "GET /sessions/:id/media/:artifactId owner=404", + "POST /sessions/:id/attachments owner=400", + "GET /sessions/:id/attachments/:attachmentId owner=404", + "GET /sessions/:id/diff owner=200", + "PUT /sessions/:id/diff owner=400", + "POST /sessions/:id/diff/failure owner=400", + "GET /sessions/:id/diff/:revisionId/files/:fileId owner=409", + "POST /sessions/:id/diff/retry owner=409", + "GET /sessions/:id/skills owner=200", + "POST /sessions/:id/children owner=400", + "GET /sessions/:id/children owner=200", + "GET /sessions/:id/children/:childId owner=404", + "POST /sessions/:id/children/:childId/cancel owner=404", + "POST /sessions/:id/slack-notify owner=400", + "GET /repos owner=500", + "PUT /repos/:owner/:name/metadata owner=200", + "GET /repos/:owner/:name/metadata owner=200", + "GET /repos/:owner/:name/branches owner=500", + "PUT /repos/:owner/:name/secrets owner=500", + "GET /repos/:owner/:name/secrets owner=500", + "DELETE /repos/:owner/:name/secrets/:key owner=500", + "PUT /secrets owner=400", + "GET /secrets owner=200", + "DELETE /secrets/:key owner=404", + "GET /environments owner=200", + "POST /environments owner=400", + "GET /environments/:id owner=404", + "PUT /environments/:id owner=404", + "DELETE /environments/:id owner=404", + "GET /environments/:id/secrets owner=404", + "PUT /environments/:id/secrets owner=404", + "POST /environments/:id/secrets/import owner=404", + "DELETE /environments/:id/secrets/:key owner=404", + "POST /image-builds/trigger/environment/:id owner=503", + "POST /image-builds/trigger/repo/:owner/:name owner=503", + "PUT /image-builds/toggle/repo/:owner/:name owner=400", + "GET /image-builds/status owner=200", + "GET /image-builds/enabled owner=200", + "GET /image-builds/enabled-repos owner=200", + "GET /model-preferences owner=200", + "PUT /model-preferences owner=400", + "GET /model-provider-accounts/legacy-credentials owner=200", + "GET /model-provider-accounts owner=200", + "POST /model-provider-accounts owner=400", + "POST /model-provider-accounts/:provider/device-authorizations owner=400", + "POST /model-provider-accounts/:provider/device-authorizations/:id/poll owner=404", + "DELETE /model-provider-accounts/:provider/device-authorizations/:id owner=404", + "GET /model-provider-accounts/:id owner=400", + "PATCH /model-provider-accounts/:id owner=400", + "POST /model-provider-accounts/:id/verify owner=400", + "POST /model-provider-accounts/:id/disable owner=400", + "POST /model-provider-accounts/:id/enable owner=400", + "POST /model-provider-accounts/:id/reconnect owner=400", + "DELETE /model-provider-accounts/:id owner=400", + "GET /model-provider-account-defaults owner=200", + "PUT /model-provider-account-defaults/:provider owner=400", + "DELETE /model-provider-account-defaults/:provider owner=204", + "GET /integration-settings/:id owner=404", + "PUT /integration-settings/:id owner=404", + "DELETE /integration-settings/:id owner=404", + "GET /integration-settings/:id/repos owner=404", + "GET /integration-settings/:id/repos/:owner/:name owner=404", + "PUT /integration-settings/:id/repos/:owner/:name owner=404", + "DELETE /integration-settings/:id/repos/:owner/:name owner=404", + "GET /integration-settings/:id/environments/:environmentId owner=404", + "PUT /integration-settings/:id/environments/:environmentId owner=404", + "DELETE /integration-settings/:id/environments/:environmentId owner=404", + "GET /integration-settings/:id/resolved/:owner/:name owner=404", + "GET /commit-signing owner=200", + "PUT /commit-signing owner=400", + "DELETE /commit-signing owner=200", + "GET /scm-settings owner=200", + "PUT /scm-settings owner=400", + "DELETE /scm-settings owner=200", + "GET /scm-settings/repos owner=200", + "PUT /scm-settings/repos/:owner/:name owner=400", + "DELETE /scm-settings/repos/:owner/:name owner=200", + "GET /integration-settings/slack/watched-channels owner=200", + "GET /integration-settings/slack/channels owner=200", + "GET /automations owner=200", + "POST /automations owner=400", + "GET /automations/:id owner=200", + "PUT /automations/:id owner=200", + "DELETE /automations/:id owner=200", + "POST /automations/:id/pause owner=404", + "POST /automations/:id/resume owner=404", + "POST /automations/:id/trigger owner=404", + "GET /automations/:id/invocations owner=404", + "GET /automations/:id/runs/:runId owner=404", + "POST /automations/:id/regenerate-key owner=404", + "GET /mcp-servers owner=200", + "POST /mcp-servers owner=400", + "GET /mcp-servers/:id owner=404", + "PUT /mcp-servers/:id owner=404", + "DELETE /mcp-servers/:id owner=404", + "GET /analytics/dashboard owner=200", + "GET /analytics/summary owner=200", + "GET /analytics/timeseries owner=200", + "GET /analytics/breakdown owner=400", + "GET /analytics/pull-requests owner=200", + "GET /audit-events owner=200", + "GET /autofix/activity owner=200", + "GET /skills owner=200", + "POST /skills/preview owner=400", + "POST /skills/resolve-preview owner=200", + "GET /skills/:id owner=404", + "POST /skills owner=400", + "POST /skills/import/preview owner=400", + "POST /skills/import owner=400", + "POST /skills/:id/reimport/preview owner=404", + "POST /skills/:id/reimport owner=428", + "PATCH /skills/:id owner=400", + "PUT /skills/:id owner=428", + "DELETE /skills/:id owner=404", + "GET /skill-profiles owner=200", + "POST /skill-profiles owner=400", + "PATCH /skill-profiles/:id owner=404", + "DELETE /skill-profiles/:id owner=404", + "GET /keyboard-shortcuts owner=200", + "PUT /keyboard-shortcuts owner=400", + "GET /me/authorization owner=200", + "GET /roles owner=200", + "GET /roles/:id owner=404", + "GET /members owner=200", + "PUT /members/:id/role owner=400", + "PUT /members/:id/status owner=400", +] +`; + +exports[`route admission matrix > rejects every credentialed route anonymously by its authentication class 1`] = ` +[ + "GET /health anonymous=200", + "POST /api/auth/sign-in/social anonymous=401", + "GET /api/auth/callback/github anonymous=401", + "GET /api/auth/callback/google anonymous=401", + "GET /api/auth/get-session anonymous=401", + "POST /api/auth/sign-out anonymous=401", + "GET /api/auth/error anonymous=401", + "GET /internal/auth/sign-in-providers anonymous=401", + "POST /sessions anonymous=401", + "GET /sessions anonymous=401", + "GET /sessions/inbox anonymous=401", + "PATCH /sessions/:id/read-state anonymous=401", + "DELETE /sessions/:id anonymous=401", + "GET /sessions/:id/sandbox-access anonymous=401", + "GET /sessions/:id anonymous=401", + "POST /sessions/:id/stop anonymous=401", + "POST /sessions/:id/sandbox-error anonymous=401", + "GET /sessions/:id/events anonymous=401", + "GET /sessions/:id/artifacts anonymous=401", + "GET /sessions/:id/participants anonymous=401", + "GET /sessions/:id/participant-profiles anonymous=401", + "GET /sessions/:id/messages anonymous=401", + "POST /sessions/:id/pr anonymous=401", + "POST /sessions/:id/openai-token-refresh anonymous=401", + "POST /sessions/:id/xai-token-refresh anonymous=401", + "POST /sessions/:id/scm-credentials anonymous=401", + "GET /sessions/:id/tunnel-urls anonymous=401", + "PATCH /sessions/:id/title anonymous=401", + "POST /sessions/:id/archive anonymous=401", + "POST /sessions/:id/unarchive anonymous=401", + "POST /sessions/:id/ws-token anonymous=401", + "POST /sessions/:id/prompt anonymous=401", + "POST /sessions/:id/pull-requests/refresh anonymous=401", + "POST /sessions/:id/media anonymous=401", + "GET /sessions/:id/media/:artifactId anonymous=401", + "POST /sessions/:id/attachments anonymous=401", + "GET /sessions/:id/attachments/:attachmentId anonymous=401", + "GET /sessions/:id/diff anonymous=401", + "PUT /sessions/:id/diff anonymous=401", + "POST /sessions/:id/diff/failure anonymous=401", + "GET /sessions/:id/diff/:revisionId/files/:fileId anonymous=401", + "POST /sessions/:id/diff/retry anonymous=401", + "GET /sessions/:id/skills anonymous=401", + "GET /sessions/:id/sandbox-skills anonymous=401", + "POST /sessions/:id/children anonymous=401", + "GET /sessions/:id/children anonymous=401", + "GET /sessions/:id/children/:childId anonymous=401", + "POST /sessions/:id/children/:childId/cancel anonymous=401", + "POST /sessions/:id/children/:childId/prompt anonymous=401", + "POST /sessions/:id/slack-notify anonymous=401", + "GET /repos anonymous=401", + "PUT /repos/:owner/:name/metadata anonymous=401", + "GET /repos/:owner/:name/metadata anonymous=401", + "GET /repos/:owner/:name/branches anonymous=401", + "PUT /repos/:owner/:name/secrets anonymous=401", + "GET /repos/:owner/:name/secrets anonymous=401", + "DELETE /repos/:owner/:name/secrets/:key anonymous=401", + "PUT /secrets anonymous=401", + "GET /secrets anonymous=401", + "DELETE /secrets/:key anonymous=401", + "GET /environments anonymous=401", + "POST /environments anonymous=401", + "GET /environments/:id anonymous=401", + "PUT /environments/:id anonymous=401", + "DELETE /environments/:id anonymous=401", + "GET /environments/:id/secrets anonymous=401", + "PUT /environments/:id/secrets anonymous=401", + "POST /environments/:id/secrets/import anonymous=401", + "DELETE /environments/:id/secrets/:key anonymous=401", + "POST /image-builds/build-complete anonymous=400", + "POST /image-builds/build-failed anonymous=400", + "POST /image-builds/trigger/environment/:id anonymous=401", + "POST /image-builds/trigger/repo/:owner/:name anonymous=401", + "PUT /image-builds/toggle/repo/:owner/:name anonymous=401", + "GET /image-builds/status anonymous=401", + "GET /image-builds/enabled anonymous=401", + "GET /image-builds/enabled-repos anonymous=401", + "GET /model-preferences anonymous=401", + "PUT /model-preferences anonymous=401", + "GET /model-provider-accounts/legacy-credentials anonymous=401", + "GET /model-provider-accounts anonymous=401", + "POST /model-provider-accounts anonymous=401", + "POST /model-provider-accounts/:provider/device-authorizations anonymous=401", + "POST /model-provider-accounts/:provider/device-authorizations/:id/poll anonymous=401", + "DELETE /model-provider-accounts/:provider/device-authorizations/:id anonymous=401", + "GET /model-provider-accounts/:id anonymous=401", + "PATCH /model-provider-accounts/:id anonymous=401", + "POST /model-provider-accounts/:id/verify anonymous=401", + "POST /model-provider-accounts/:id/disable anonymous=401", + "POST /model-provider-accounts/:id/enable anonymous=401", + "POST /model-provider-accounts/:id/reconnect anonymous=401", + "DELETE /model-provider-accounts/:id anonymous=401", + "GET /model-provider-account-defaults anonymous=401", + "PUT /model-provider-account-defaults/:provider anonymous=401", + "DELETE /model-provider-account-defaults/:provider anonymous=401", + "POST /sessions/:id/provider-auth/:provider/access-token anonymous=401", + "GET /integration-settings/:id anonymous=401", + "PUT /integration-settings/:id anonymous=401", + "DELETE /integration-settings/:id anonymous=401", + "GET /integration-settings/:id/repos anonymous=401", + "GET /integration-settings/:id/repos/:owner/:name anonymous=401", + "PUT /integration-settings/:id/repos/:owner/:name anonymous=401", + "DELETE /integration-settings/:id/repos/:owner/:name anonymous=401", + "GET /integration-settings/:id/environments/:environmentId anonymous=401", + "PUT /integration-settings/:id/environments/:environmentId anonymous=401", + "DELETE /integration-settings/:id/environments/:environmentId anonymous=401", + "GET /integration-settings/:id/resolved/:owner/:name anonymous=401", + "GET /commit-signing anonymous=401", + "PUT /commit-signing anonymous=401", + "DELETE /commit-signing anonymous=401", + "GET /sessions/:id/commit-signing anonymous=401", + "POST /sessions/:id/commit-signing anonymous=401", + "GET /scm-settings anonymous=401", + "PUT /scm-settings anonymous=401", + "DELETE /scm-settings anonymous=401", + "GET /scm-settings/repos anonymous=401", + "PUT /scm-settings/repos/:owner/:name anonymous=401", + "DELETE /scm-settings/repos/:owner/:name anonymous=401", + "GET /integration-settings/slack/watched-channels anonymous=401", + "GET /integration-settings/slack/channels anonymous=401", + "GET /automations anonymous=401", + "POST /automations anonymous=401", + "GET /automations/:id anonymous=401", + "PUT /automations/:id anonymous=401", + "DELETE /automations/:id anonymous=401", + "POST /automations/:id/pause anonymous=401", + "POST /automations/:id/resume anonymous=401", + "POST /automations/:id/trigger anonymous=401", + "GET /automations/:id/invocations anonymous=401", + "GET /automations/:id/runs/:runId anonymous=401", + "POST /automations/:id/regenerate-key anonymous=401", + "GET /mcp-servers anonymous=401", + "POST /mcp-servers anonymous=401", + "GET /mcp-servers/:id anonymous=401", + "PUT /mcp-servers/:id anonymous=401", + "DELETE /mcp-servers/:id anonymous=401", + "GET /analytics/dashboard anonymous=401", + "GET /analytics/summary anonymous=401", + "GET /analytics/timeseries anonymous=401", + "GET /analytics/breakdown anonymous=401", + "GET /analytics/pull-requests anonymous=401", + "GET /audit-events anonymous=401", + "GET /autofix/activity anonymous=401", + "GET /skills anonymous=401", + "POST /skills/preview anonymous=401", + "POST /skills/resolve-preview anonymous=401", + "GET /skills/:id anonymous=401", + "POST /skills anonymous=401", + "POST /skills/import/preview anonymous=401", + "POST /skills/import anonymous=401", + "POST /skills/:id/reimport/preview anonymous=401", + "POST /skills/:id/reimport anonymous=401", + "PATCH /skills/:id anonymous=401", + "PUT /skills/:id anonymous=401", + "DELETE /skills/:id anonymous=401", + "GET /skill-profiles anonymous=401", + "POST /skill-profiles anonymous=401", + "PATCH /skill-profiles/:id anonymous=401", + "DELETE /skill-profiles/:id anonymous=401", + "GET /keyboard-shortcuts anonymous=401", + "PUT /keyboard-shortcuts anonymous=401", + "GET /me/authorization anonymous=401", + "GET /roles anonymous=401", + "GET /roles/:id anonymous=401", + "GET /members anonymous=401", + "PUT /members/:id/role anonymous=401", + "PUT /members/:id/status anonymous=401", + "POST /webhooks/sentry/:id anonymous=404", + "POST /webhooks/automation/:id anonymous=415", + "POST /internal/github-event anonymous=401", + "POST /internal/slack-event anonymous=401", +] +`; diff --git a/packages/control-plane/test/integration/auth-sign-in-claim.test.ts b/packages/control-plane/test/integration/auth-sign-in-claim.test.ts index 7ff7ce71c6..df9bbdf3fa 100644 --- a/packages/control-plane/test/integration/auth-sign-in-claim.test.ts +++ b/packages/control-plane/test/integration/auth-sign-in-claim.test.ts @@ -1,11 +1,10 @@ import { createExecutionContext, env } from "cloudflare:test"; import { getSetCookies } from "./helpers"; -import { createCloudflareBackgroundTasks } from "../../src/cloudflare/background-tasks"; import { BROWSER_AUTH_CLIENT_IP_HEADER } from "@open-inspect/shared/browser-auth-routes"; import { buildServiceAuthHeaders } from "@open-inspect/shared/service-auth"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { UserStore } from "../../src/db/user-store"; -import { handleRequest as routeRequest } from "../../src/router"; +import { handleControlPlaneHttp as routeRequest } from "../../src/routing/hono-app"; import { cleanD1Tables } from "./cleanup"; import { createSignedGoogleIdToken } from "./google-id-token"; import { @@ -30,11 +29,7 @@ function handleRequest( request: Request, requestEnv: Parameters[1] ): Promise { - return routeRequest( - request, - requestEnv, - createCloudflareBackgroundTasks(createExecutionContext()) - ); + return routeRequest(request, requestEnv, createExecutionContext()); } const PUBLIC_WEB_ORIGIN = "https://app.test.local"; const WEB_SERVICE_SECRET = "test-service-secret-web"; diff --git a/packages/control-plane/test/integration/browser-auth-callback.test.ts b/packages/control-plane/test/integration/browser-auth-callback.test.ts index df32d4b147..75873cc9fd 100644 --- a/packages/control-plane/test/integration/browser-auth-callback.test.ts +++ b/packages/control-plane/test/integration/browser-auth-callback.test.ts @@ -1,6 +1,5 @@ import { createExecutionContext, env } from "cloudflare:test"; import { getSetCookies } from "./helpers"; -import { createCloudflareBackgroundTasks } from "../../src/cloudflare/background-tasks"; import { isCanonicalUserId } from "@open-inspect/shared/user-id"; import { buildServiceAuthHeaders } from "@open-inspect/shared/service-auth"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -8,7 +7,7 @@ import { getUserAuth } from "../../src/auth/user/runtime"; import { resolveGitHubCredentialAuthority } from "../../src/source-control/github-credential-authority"; import { decryptToken } from "../../src/auth/crypto"; import { UserStore } from "../../src/db/user-store"; -import { handleRequest as routeRequest } from "../../src/router"; +import { handleControlPlaneHttp as routeRequest } from "../../src/routing/hono-app"; import { resolveGitHubEnrichmentForRequest } from "../../src/session/identity"; import { cleanD1Tables } from "./cleanup"; import { createSignedGoogleIdToken } from "./google-id-token"; @@ -25,11 +24,7 @@ function handleRequest( request: Request, requestEnv: Parameters[1] ): Promise { - return routeRequest( - request, - requestEnv, - createCloudflareBackgroundTasks(createExecutionContext()) - ); + return routeRequest(request, requestEnv, createExecutionContext()); } let googleIdToken = ""; diff --git a/packages/control-plane/test/integration/browser-auth-router.test.ts b/packages/control-plane/test/integration/browser-auth-router.test.ts index 4c29ecedd2..531e980855 100644 --- a/packages/control-plane/test/integration/browser-auth-router.test.ts +++ b/packages/control-plane/test/integration/browser-auth-router.test.ts @@ -1,8 +1,7 @@ import { createExecutionContext, env } from "cloudflare:test"; -import { createCloudflareBackgroundTasks } from "../../src/cloudflare/background-tasks"; import { buildServiceAuthHeaders, type ServiceName } from "@open-inspect/shared/service-auth"; import { describe, expect, it } from "vitest"; -import { handleRequest as routeRequest } from "../../src/router"; +import { handleControlPlaneHttp as routeRequest } from "../../src/routing/hono-app"; import type { Env } from "../../src/types"; const CONTROL_PLANE_ORIGIN = "https://control-plane.test.local"; @@ -13,11 +12,7 @@ function handleRequest( request: Request, requestEnv: Parameters[1] ): Promise { - return routeRequest( - request, - requestEnv, - createCloudflareBackgroundTasks(createExecutionContext()) - ); + return routeRequest(request, requestEnv, createExecutionContext()); } async function signedServiceRequest( diff --git a/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts b/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts new file mode 100644 index 0000000000..1c68cfc38b --- /dev/null +++ b/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts @@ -0,0 +1,76 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import { routes } from "../../src/routes/catalog"; +import { NO_AUTHORIZATION, type Route } from "../../src/routes/shared"; +import { createControlPlaneHttpHandler } from "../../src/routing/hono-app"; +import type { Env } from "../../src/types"; + +const PARAMETER = /:(\w+)/g; + +function materializePath( + routePath: string, + routeIndex: number +): { pathname: string; groups: Record } { + const groups: Record = {}; + const pathname = routePath.replace(PARAMETER, (_parameter, name: string) => { + // An encoded slash remains one raw URL.pathname segment. It detects any + // decoding before Hono selection or before the legacy-compatible regex. + const value = `fixture-${routeIndex}-${name}%2Fraw`; + groups[name] = value; + return value; + }); + return { pathname, groups }; +} + +describe("Hono route catalog conformance", () => { + it("dispatches every frozen method/path/policy entry with raw captures", async () => { + const manifest = routes.map((route, routeIndex) => { + const { pathname, groups } = materializePath(route.path, routeIndex); + return { + identity: `${route.method} ${route.path}`, + pathname, + groups, + pattern: route.pattern.source, + authentication: route.authentication.kind, + authorization: route.authorization, + supportedScmProviders: route.supportedScmProviders, + cacheControl: route.cacheControl ?? null, + hasServiceActorClaims: route.serviceActorClaims !== undefined, + }; + }); + + expect(manifest).toHaveLength(171); + // One compact, reviewable line per frozen route keeps the fixture explicit + // without thousands of snapshot-only formatting lines. + expect(manifest.map((entry) => JSON.stringify(entry))).toMatchSnapshot(); + + // A shadow catalog keeps the production method/path/order and replaces + // each policy with a public echo handler, so selection and raw captures + // are observed without mutating the production route objects. + const shadow: Route[] = routes.map((route, routeIndex) => ({ + ...route, + authentication: { kind: "public" }, + authorization: NO_AUTHORIZATION, + serviceActorClaims: undefined, + supportedScmProviders: "all", + handler: async (_request, _env, match) => + Response.json({ identity: manifest[routeIndex].identity, groups: match.groups ?? {} }), + })); + const handle = createControlPlaneHttpHandler(shadow); + + for (const [routeIndex, route] of routes.entries()) { + const { identity: expectedIdentity, pathname, groups } = manifest[routeIndex]; + const response = await handle( + new Request(`https://test.local${pathname}`, { method: route.method }), + env as unknown as Env, + createExecutionContext() + ); + + expect(response.status, expectedIdentity).toBe(200); + await expect(response.json(), expectedIdentity).resolves.toEqual({ + identity: expectedIdentity, + groups, + }); + } + }); +}); diff --git a/packages/control-plane/test/integration/response-compatibility.test.ts b/packages/control-plane/test/integration/response-compatibility.test.ts new file mode 100644 index 0000000000..d833edc964 --- /dev/null +++ b/packages/control-plane/test/integration/response-compatibility.test.ts @@ -0,0 +1,313 @@ +import { buildServiceAuthHeaders, type ServiceName } from "@open-inspect/shared/service-auth"; +import { createExecutionContext, env, SELF } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import worker from "../../src/index"; +import type { Env } from "../../src/types"; +import { cleanD1Tables } from "./cleanup"; +import { + getSetCookies, + initNamedSession, + queryDO, + seedMessage, + seedSandboxAuthHash, + serviceFetch, +} from "./helpers"; + +const MP4_BYTES = Uint8Array.from([ + 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00, 0x00, 0x02, 0x00, + 0x69, 0x73, 0x6f, 0x6d, 0x69, 0x73, 0x6f, 0x32, +]); + +const SERVICE_SECRETS: Record = { + web: "test-service-secret-web", + "github-bot": "test-service-secret-github-bot", + "slack-bot": "test-service-secret-slack-bot", + "linear-bot": "test-service-secret-linear-bot", +}; + +function fetchWorker(request: Request, requestEnv: Env = env): Promise { + return worker.fetch(request, requestEnv, createExecutionContext()); +} + +async function signedRequest(input: { + url: string; + method?: string; + service: ServiceName; + body?: string; + actor?: string; + headers?: Record; +}): Promise { + const method = input.method ?? "GET"; + return new Request(input.url, { + method, + headers: { + ...(input.body === undefined ? {} : { "Content-Type": "application/json" }), + ...input.headers, + ...(await buildServiceAuthHeaders({ + service: input.service, + secret: SERVICE_SECRETS[input.service], + method, + url: input.url, + body: input.body, + actor: input.actor, + })), + }, + body: input.body, + }); +} + +function expectCommonResponseHeaders(response: Response, traceId: string): void { + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("x-request-id")).toBeTruthy(); + expect(response.headers.get("x-trace-id")).toBe(traceId); +} + +async function uploadVideoFixture(): Promise<{ artifactId: string; sessionName: string }> { + const sessionName = `response-compat-${crypto.randomUUID()}`; + const sandboxToken = `sandbox-${crypto.randomUUID()}`; + const { stub } = await initNamedSession(sessionName); + await seedSandboxAuthHash(stub, { + authToken: sandboxToken, + sandboxId: "sandbox-response-compat", + }); + + const participants = await queryDO<{ id: string }>( + stub, + "SELECT id FROM participants WHERE user_id = ?", + "user-1" + ); + const participantId = participants[0]?.id; + if (!participantId) throw new Error("Missing media fixture participant"); + await seedMessage(stub, { + id: `message-${crypto.randomUUID()}`, + authorId: participantId, + content: "Record response compatibility", + source: "sandbox", + status: "processing", + createdAt: Date.now() - 1_000, + startedAt: Date.now() - 500, + }); + + const formData = new FormData(); + formData.append("file", new File([MP4_BYTES], "recording.mp4", { type: "video/mp4" })); + formData.append("artifactType", "video"); + formData.append("caption", "Response compatibility recording"); + formData.append("durationMs", "2500"); + formData.append("recordingStartedAt", "1000"); + formData.append("recordingEndedAt", "3500"); + formData.append("dimensions", '{"width":1280,"height":720}'); + formData.append("truncated", "false"); + formData.append("hasAudio", "false"); + + const response = await SELF.fetch(`https://test.local/sessions/${sessionName}/media`, { + method: "POST", + headers: { Authorization: `Bearer ${sandboxToken}` }, + body: formData, + }); + expect(response.status).toBe(201); + const body = await response.json<{ artifactId: string }>(); + return { artifactId: body.artifactId, sessionName }; +} + +beforeEach(cleanD1Tables); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("ordinary HTTP response compatibility", () => { + it("keeps a missing database binding failure undecorated at the Worker boundary", async () => { + const response = await fetchWorker( + new Request("https://test.local/health", { + headers: { "x-trace-id": "missing-db-trace" }, + }), + { ...env, DB: undefined } as unknown as Env + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ error: "Database not configured" }); + expect(response.headers.get("Content-Type")).toBe("application/json"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(response.headers.get("x-request-id")).toBeNull(); + expect(response.headers.get("x-trace-id")).toBeNull(); + expect(response.headers.get("Cache-Control")).toBeNull(); + }); + + it("decorates an SCM provider rejection with the common response headers", async () => { + const traceId = "scm-rejection-trace"; + const request = await signedRequest({ + url: "https://test.local/repos", + service: "slack-bot", + headers: { "x-trace-id": traceId }, + }); + + const response = await fetchWorker(request, { ...env, SCM_PROVIDER: "gitlab" } as Env); + + expect(response.status).toBe(501); + await expect(response.json()).resolves.toEqual({ + error: "SCM provider 'gitlab' is not implemented in this deployment.", + }); + expectCommonResponseHeaders(response, traceId); + expect(response.headers.get("Cache-Control")).toBeNull(); + }); + + it("applies a matched route's cache policy to an authentication rejection", async () => { + const traceId = "route-cache-trace"; + const response = await SELF.fetch("https://test.local/roles", { + headers: { "x-trace-id": traceId }, + }); + + expect(response.status).toBe(401); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + expectCommonResponseHeaders(response, traceId); + }); + + it("maps a handler HttpError to JSON while retaining common headers", async () => { + const traceId = "http-error-trace"; + const body = JSON.stringify({ + environmentId: `missing-${crypto.randomUUID()}`, + title: "Missing environment compatibility request", + model: "anthropic/claude-haiku-4-5", + }); + const request = await signedRequest({ + url: "https://test.local/sessions", + method: "POST", + service: "slack-bot", + actor: `slack:response-compat-${crypto.randomUUID()}`, + headers: { "x-trace-id": traceId }, + body, + }); + + const response = await fetchWorker(request); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: expect.stringMatching(/^Environment not found: missing-/), + }); + expectCommonResponseHeaders(response, traceId); + }); + + it("preserves repeated Set-Cookie headers on a browser-auth redirect", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://github.com/login/oauth/access_token") { + return Response.json({ access_token: "response-compat-token", token_type: "bearer" }); + } + if (url === "https://api.github.com/user") { + return Response.json({ + id: 908_172, + login: "response-compat-user", + name: "Response Compat User", + avatar_url: "https://avatars.example/response-compat-user", + }); + } + if (url.startsWith("https://api.github.com/user/emails")) { + return Response.json([ + { + email: "response-compat@example.com", + primary: true, + verified: true, + visibility: "private", + }, + ]); + } + throw new Error(`Unexpected external request: ${url}`); + }); + + const initiationBody = JSON.stringify({ + provider: "github", + callbackURL: "/after-response-compat", + disableRedirect: true, + }); + const initiation = await fetchWorker( + await signedRequest({ + url: "https://test.local/api/auth/sign-in/social", + method: "POST", + service: "web", + body: initiationBody, + }) + ); + expect(initiation.status).toBe(200); + const providerUrl = new URL((await initiation.json<{ url: string }>()).url); + const state = providerUrl.searchParams.get("state"); + expect(state).toBeTruthy(); + const stateCookie = getSetCookies(initiation.headers).find((value) => + value.startsWith("__Secure-openinspect.state=") + ); + expect(stateCookie).toBeTruthy(); + + const callbackUrl = `https://test.local/api/auth/callback/github?code=response-compat-code&state=${encodeURIComponent(state ?? "")}`; + const traceId = "browser-redirect-trace"; + const callback = await fetchWorker( + await signedRequest({ + url: callbackUrl, + service: "web", + headers: { + Cookie: stateCookie?.split(";", 1)[0] ?? "", + "x-trace-id": traceId, + }, + }) + ); + + expect(callback.status).toBe(302); + expect(callback.headers.get("Location")).toBe("/after-response-compat"); + const callbackCookies = getSetCookies(callback.headers); + expect(callbackCookies.some((value) => value.startsWith("__Secure-openinspect.state="))).toBe( + true + ); + expect( + callbackCookies.some((value) => value.startsWith("__Secure-openinspect.session_token=")) + ).toBe(true); + expect(callbackCookies.length).toBeGreaterThanOrEqual(2); + expectCommonResponseHeaders(callback, traceId); + }); + + it("preserves a streamed 206 response and its byte-range headers", async () => { + const { artifactId, sessionName } = await uploadVideoFixture(); + const traceId = "range-stream-trace"; + const response = await serviceFetch( + `https://test.local/sessions/${sessionName}/media/${artifactId}`, + { + service: "slack-bot", + headers: { Range: "bytes=4-11", "x-trace-id": traceId }, + } + ); + + expect(response.status).toBe(206); + expect(response.headers.get("Content-Type")).toBe("video/mp4"); + expect(response.headers.get("Accept-Ranges")).toBe("bytes"); + expect(response.headers.get("Content-Range")).toBe(`bytes 4-11/${MP4_BYTES.byteLength}`); + expect(response.headers.get("Content-Length")).toBe("8"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(MP4_BYTES.slice(4, 12)); + expectCommonResponseHeaders(response, traceId); + }); + + it("maps an unknown handler exception to the generic decorated 500 response", async () => { + const { artifactId, sessionName } = await uploadVideoFixture(); + const traceId = "unknown-handler-error-trace"; + const url = `https://test.local/sessions/${sessionName}/media/${artifactId}`; + const request = await signedRequest({ + url, + service: "slack-bot", + headers: { Range: "bytes=0-3", "x-trace-id": traceId }, + }); + const unavailableBucket = { + head: async () => ({ + size: MP4_BYTES.byteLength, + httpEtag: '"response-compat"', + writeHttpMetadata: () => { + throw new Error("R2 response compatibility failure"); + }, + }), + get: async () => ({ + body: new Blob([MP4_BYTES.slice(0, 4)]).stream(), + }), + } as unknown as R2Bucket; + + const response = await fetchWorker(request, { ...env, MEDIA_BUCKET: unavailableBucket } as Env); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ error: "Internal server error" }); + expectCommonResponseHeaders(response, traceId); + }); +}); diff --git a/packages/control-plane/test/integration/route-admission-matrix.test.ts b/packages/control-plane/test/integration/route-admission-matrix.test.ts new file mode 100644 index 0000000000..f3151d3d26 --- /dev/null +++ b/packages/control-plane/test/integration/route-admission-matrix.test.ts @@ -0,0 +1,269 @@ +/** + * Drives every catalog route through the deployed Worker with each credential + * class it can meet, so each endpoint has one Request/Response observation of + * its Hono selection and admission outcome. + * + * The invariants assert admission behavior per authentication class. The + * snapshots freeze the observed status per route so a change in any + * endpoint's admission or handler-owned outcome is a reviewable diff. + */ + +import { SELF, env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; +import { routes } from "../../src/routes/catalog"; +import type { Route } from "../../src/routes/shared"; +import { cleanD1Tables } from "./cleanup"; +import { initSession, seedSandboxAuth, serviceFetch, waitForSandboxStatus } from "./helpers"; + +const BASE = "https://test.local"; +const BROWSER_USER_ID = "11111111111111111111111111111111"; +const SANDBOX_TOKEN = "matrix-sandbox-token"; +const BOT_SERVICES = ["slack-bot", "github-bot", "linear-bot"] as const; +const PROTECTED_STATUSES = new Set([401, 403]); +const ROUTE_MISS_BODY = JSON.stringify({ error: "Not found" }); +// Each pass issues one request per catalog route, and a fresh session per +// mutating session route, so the default per-test budget is too small under +// full-suite load. +const MATRIX_TIMEOUT_MS = 60_000; + +interface MatrixFixtures { + readonlySessionId: string; + sandboxSessionId: string; + automationId: string; +} + +function automation(id: string, userId: string): AutomationRow { + return { + id, + name: id, + instructions: "Run tests", + trigger_type: "schedule", + schedule_cron: "0 9 * * *", + schedule_tz: "UTC", + event_type: null, + trigger_config: null, + trigger_auth_data: null, + model: "anthropic/claude-sonnet-4-6", + reasoning_effort: null, + enabled: 1, + next_run_at: null, + consecutive_failures: 0, + created_by: userId, + user_id: userId, + created_at: 1, + updated_at: 1, + deleted_at: null, + }; +} + +const PARAMETER_VALUES: Record = { + owner: "acme", + name: "web-app", + provider: "openai", + key: "MATRIX_KEY", +}; + +function materialize(route: Route, values: Record): string { + return route.path.replace(/:(\w+)/g, (_parameter, parameter: string) => { + return values[parameter] ?? PARAMETER_VALUES[parameter] ?? `matrix-${parameter}`; + }); +} + +function isSessionRoute(route: Route): boolean { + return route.path.startsWith("/sessions/:id"); +} + +function isMutation(route: Route): boolean { + return route.method !== "GET"; +} + +async function createReadySession(): Promise { + const { stub, sessionName } = await initSession({ userId: BROWSER_USER_ID }); + await waitForSandboxStatus(stub, "failed"); + return sessionName; +} + +async function bodyText(response: Response): Promise { + return response.text(); +} + +function outcome(label: string, status: number): string { + return `${label}=${status}`; +} + +describe("route admission matrix", { timeout: MATRIX_TIMEOUT_MS }, () => { + const fixtures: MatrixFixtures = { + readonlySessionId: "", + sandboxSessionId: "", + automationId: "", + }; + + beforeAll(async () => { + await cleanD1Tables(); + // Enroll the browser owner so seeded resources can be attributed to it. + expect((await serviceFetch(`${BASE}/me/authorization`)).status).toBe(200); + fixtures.readonlySessionId = await createReadySession(); + + const { stub, sessionName } = await initSession({ userId: BROWSER_USER_ID }); + await seedSandboxAuth(stub, { authToken: SANDBOX_TOKEN, sandboxId: "sb-matrix" }); + fixtures.sandboxSessionId = sessionName; + + fixtures.automationId = "matrix-automation"; + await new AutomationStore(env.DB).create(automation(fixtures.automationId, BROWSER_USER_ID)); + }, MATRIX_TIMEOUT_MS); + + it("rejects every credentialed route anonymously by its authentication class", async () => { + const observed: string[] = []; + for (const route of routes) { + const url = `${BASE}${materialize(route, { id: "matrix-anonymous" })}`; + const response = await SELF.fetch(url, { method: route.method }); + const identity = `${route.method} ${route.path}`; + observed.push(`${identity} ${outcome("anonymous", response.status)}`); + + expect(response.headers.get("x-request-id"), identity).toBeTruthy(); + expect(response.headers.get("x-trace-id"), identity).toBeTruthy(); + expect(response.headers.get("Access-Control-Allow-Origin"), identity).toBe("*"); + + switch (route.authentication.kind) { + case "public": + expect(response.status, identity).toBe(200); + break; + case "handler-authenticated": + // The handler owns credential verification and its own error order. + expect(response.status, identity).toBeGreaterThanOrEqual(400); + expect(response.status, identity).toBeLessThan(500); + break; + default: + expect(response.status, identity).toBe(401); + expect(await bodyText(response), identity).not.toBe(ROUTE_MISS_BODY); + } + } + expect(observed).toMatchSnapshot(); + }); + + it("admits the workspace owner through every browser-reachable route", async () => { + const observed: string[] = []; + for (const route of routes) { + const kind = route.authentication.kind; + if (kind === "sandbox" || kind === "service" || kind === "public") continue; + if (kind === "handler-authenticated") continue; + + const sessionId = + isSessionRoute(route) && isMutation(route) + ? await createReadySession() + : fixtures.readonlySessionId; + const url = `${BASE}${materialize(route, { id: route.path.startsWith("/automations/") ? fixtures.automationId : sessionId })}`; + const response = await serviceFetch(url, { + method: route.method, + ...(isMutation(route) ? { body: "{}" } : {}), + }); + const identity = `${route.method} ${route.path}`; + observed.push(`${identity} ${outcome("owner", response.status)}`); + + // Raw web-service routes (browser auth, autofix activity) admit the web + // principal and then let their handler own every status, including 403. + if (kind !== "web-service") { + expect(PROTECTED_STATUSES.has(response.status), `${identity} -> ${response.status}`).toBe( + false + ); + } + expect(response.headers.get("x-request-id"), identity).toBeTruthy(); + if (response.status === 404) { + expect(await bodyText(response), identity).not.toBe(ROUTE_MISS_BODY); + } + if (route.cacheControl) { + expect(response.headers.get("Cache-Control"), identity).toBe(route.cacheControl); + } + } + expect(observed).toMatchSnapshot(); + }); + + it("admits only the named bot on every exact-service route", async () => { + const observed: string[] = []; + const serviceRoutes = routes.filter((route) => route.authentication.kind === "service"); + expect(serviceRoutes.length).toBeGreaterThan(0); + + for (const route of serviceRoutes) { + const identity = `${route.method} ${route.path}`; + if (route.authorization.kind !== "service") { + throw new Error(`${identity} declares service authentication without a service policy`); + } + const url = `${BASE}${materialize(route, {})}`; + const services: readonly string[] = route.authorization.services; + const allowedService = route.authorization.services[0]; + const deniedService = BOT_SERVICES.find((service) => !services.includes(service)); + if (!deniedService) throw new Error(`${identity} admits every bot service`); + + const admitted = await serviceFetch(url, { + method: route.method, + service: allowedService, + body: "{}", + }); + expect(PROTECTED_STATUSES.has(admitted.status), `${identity} allowed bot`).toBe(false); + + const wrongBot = await serviceFetch(url, { + method: route.method, + service: deniedService, + body: "{}", + }); + expect(wrongBot.status, `${identity} wrong bot`).toBe(403); + await expect(wrongBot.json(), identity).resolves.toMatchObject({ + code: "service_capability_required", + }); + + const browser = await serviceFetch(url, { method: route.method, body: "{}" }); + expect(browser.status, `${identity} browser owner`).toBe(403); + await expect(browser.json(), identity).resolves.toMatchObject({ + code: "service_capability_required", + }); + + observed.push( + `${identity} ${outcome(allowedService, admitted.status)} ${outcome(deniedService, wrongBot.status)} ${outcome("web", browser.status)}` + ); + } + expect(observed).toMatchSnapshot(); + }); + + it("admits a session-bound sandbox token on every sandbox-accepting route", async () => { + const observed: string[] = []; + const sandboxRoutes = routes.filter( + (route) => + route.authentication.kind === "sandbox" || + route.authentication.kind === "user-or-service-with-sandbox-fallback" + ); + expect(sandboxRoutes.length).toBeGreaterThan(0); + + for (const route of sandboxRoutes) { + const identity = `${route.method} ${route.path}`; + const url = `${BASE}${materialize(route, { id: fixtures.sandboxSessionId })}`; + const init = { + method: route.method, + headers: { + Authorization: `Bearer ${SANDBOX_TOKEN}`, + ...(isMutation(route) ? { "Content-Type": "application/json" } : {}), + }, + ...(isMutation(route) ? { body: "{}" } : {}), + }; + + const admitted = await SELF.fetch(url, init); + expect(PROTECTED_STATUSES.has(admitted.status), `${identity} -> ${admitted.status}`).toBe( + false + ); + if (admitted.status === 404) { + expect(await bodyText(admitted), identity).not.toBe(ROUTE_MISS_BODY); + } + + const wrongToken = await SELF.fetch(url, { + ...init, + headers: { ...init.headers, Authorization: "Bearer not-the-sandbox-token" }, + }); + expect(wrongToken.status, `${identity} wrong token`).toBe(401); + + observed.push( + `${identity} ${outcome("sandbox", admitted.status)} ${outcome("wrong-token", wrongToken.status)}` + ); + } + expect(observed).toMatchSnapshot(); + }); +}); diff --git a/packages/control-plane/test/integration/routing-compatibility.test.ts b/packages/control-plane/test/integration/routing-compatibility.test.ts new file mode 100644 index 0000000000..ebd7fe9996 --- /dev/null +++ b/packages/control-plane/test/integration/routing-compatibility.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { env, SELF } from "cloudflare:test"; +import { + ACTOR_HEADER, + SERVICE_HEADER, + SERVICE_SIGNATURE_HEADER, +} from "@open-inspect/shared/service-auth"; +import { cleanD1Tables } from "./cleanup"; +import { serviceFetch } from "./helpers"; + +function expectCommonResponseHeaders(response: Response): void { + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("x-request-id")).toMatch(/^[0-9a-f]{8}$/); + expect(response.headers.get("x-trace-id")).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); +} + +async function expectJsonNotFound(response: Response, message = "Not found"): Promise { + expect(response.status).toBe(404); + expect(response.headers.get("Content-Type")).toBe("application/json"); + expectCommonResponseHeaders(response); + await expect(response.json()).resolves.toEqual({ error: message }); +} + +describe("Worker routing compatibility", () => { + beforeEach(cleanD1Tables); + + it("treats HEAD as an unsupported method instead of implicitly dispatching GET", async () => { + const response = await SELF.fetch("https://test.local/health", { method: "HEAD" }); + + expect(response.status).toBe(404); + expect(response.headers.get("Content-Type")).toBe("application/json"); + expectCommonResponseHeaders(response); + // HTTP runtimes strip response bodies from HEAD requests even though the + // selected legacy response is the JSON Not found response. + await expect(response.text()).resolves.toBe(""); + }); + + it("matches static paths against their raw encoded form", async () => { + await expectJsonNotFound(await SELF.fetch("https://test.local/he%61lth")); + }); + + it.each([ + ["GET", "/definitely-unknown"], + ["PUT", "/health"], + ])("rejects unmatched %s %s before inspecting credentials", async (method, path) => { + const response = await SELF.fetch(`https://test.local${path}`, { + method, + headers: { + [SERVICE_HEADER]: "slack-bot", + [SERVICE_SIGNATURE_HEADER]: "sig1.not-a-timestamp.nonce.signature", + [ACTOR_HEADER]: "slack:unmatched-route", + }, + }); + + await expectJsonNotFound(response); + const users = await env.DB.prepare("SELECT COUNT(*) AS count FROM users").first<{ + count: number; + }>(); + expect(users?.count).toBe(0); + }); + + it("prefers the literal inbox route but treats its encoded alias as a dynamic session id", async () => { + const literalResponse = await serviceFetch("https://test.local/sessions/inbox"); + expect(literalResponse.status).toBe(200); + expect(await literalResponse.json()).toMatchObject({ + categories: { + finished: { items: [] }, + in_progress: { items: [] }, + needs_attention: { items: [] }, + }, + }); + + const encodedResponse = await serviceFetch("https://test.local/sessions/%69nbox"); + await expectJsonNotFound(encodedResponse, "Session not found"); + }); + + it.each(["/health/", "//health", "/Health"])( + "does not normalize the strict path %s", + async (path) => { + await expectJsonNotFound(await SELF.fetch(`https://test.local${path}`)); + } + ); + + it("passes a malformed percent escape through raw dynamic matching", async () => { + const response = await serviceFetch("https://test.local/sessions/%E0%A4%A"); + + await expectJsonNotFound(response, "Session not found"); + }); + + it("returns the universal preflight response for an unknown path", async () => { + const response = await SELF.fetch("https://test.local/definitely-unknown", { + method: "OPTIONS", + headers: { "x-trace-id": "routing-compatibility-trace" }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBeNull(); + expect(Object.fromEntries(response.headers.entries())).toEqual({ + "access-control-allow-headers": "Content-Type, Authorization", + "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + "access-control-allow-origin": "*", + "access-control-max-age": "86400", + "x-request-id": expect.stringMatching(/^[0-9a-f]{8}$/), + "x-trace-id": "routing-compatibility-trace", + }); + await expect(response.text()).resolves.toBe(""); + }); +}); diff --git a/packages/control-plane/test/integration/service-auth.test.ts b/packages/control-plane/test/integration/service-auth.test.ts index c1146490e1..8b4fe390f6 100644 --- a/packages/control-plane/test/integration/service-auth.test.ts +++ b/packages/control-plane/test/integration/service-auth.test.ts @@ -11,7 +11,6 @@ import { generateInternalToken } from "@open-inspect/shared/auth"; import { GlobalSecretsStore } from "../../src/db/global-secrets"; import { UserStore } from "../../src/db/user-store"; import { cleanD1Tables } from "./cleanup"; -import { insertCanonicalUser } from "./identity-seed-helpers"; const SERVICE_SECRET: Record = { web: "test-service-secret-web", @@ -433,52 +432,397 @@ describe("sig1 service-credential authentication", () => { }); }); - it("does not authorize a first-contact actor as one user and attribute it to a Viewer", async () => { - await insertCanonicalUser({ - id: "existing-viewer", - email: "viewer@example.com", - emailVerified: 1, + it("denies a first-contact Slack actor using the Viewer role selected by its attested email", async () => { + const users = new UserStore(env.DB); + const viewer = await users.createUser({ displayName: "Existing Viewer", + email: "viewer@corp.test", + emailVerified: true, }); await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") - .bind("role_builtin_viewer", "existing-viewer") + .bind("role_builtin_viewer", viewer.id) .run(); - const body = JSON.stringify({ - title: "First-contact actor", - model: "anthropic/claude-haiku-4-5", - actorEmail: "viewer@example.com", + const response = await signedFetch({ + service: "slack-bot", + method: "POST", + url: "https://test.local/sessions", + actor: "slack:U-FIRST-CONTACT-VIEWER", + body: JSON.stringify({ + title: "Must not be created", + model: "anthropic/claude-haiku-4-5", + actorEmail: "viewer@corp.test", + }), + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + code: "permission_required", + permission: "sessions.create", + }); + await expect(users.getIdentity("slack", "U-FIRST-CONTACT-VIEWER")).resolves.toMatchObject({ + userId: viewer.id, + }); + await expect( + env.DB.prepare( + `SELECT + (SELECT COUNT(*) FROM sessions) AS sessions, + (SELECT COUNT(*) FROM users) AS users` + ).first<{ sessions: number; users: number }>() + ).resolves.toEqual({ sessions: 0, users: 1 }); + }); + + it("rejects bot automation creation at the service ceiling without enrolling its actor", async () => { + const response = await signedFetch({ + service: "slack-bot", + method: "POST", + url: "https://test.local/automations", + actor: "slack:U-AUTOMATION-DENIED", + body: JSON.stringify({ + name: "Denied automation", + instructions: "Must not run", + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + }), + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + code: "service_capability_required", + }); + await expect( + env.DB.prepare( + `SELECT + (SELECT COUNT(*) FROM users) AS users, + (SELECT COUNT(*) FROM user_identities) AS identities, + (SELECT COUNT(*) FROM user_role_assignments) AS assignments, + (SELECT COUNT(*) FROM automations) AS automations` + ).first<{ + users: number; + identities: number; + assignments: number; + automations: number; + }>() + ).resolves.toEqual({ users: 0, identities: 0, assignments: 0, automations: 0 }); + }); + + it("does not enroll an actor on an exact-service internal route", async () => { + const response = await signedFetch({ + service: "slack-bot", + method: "POST", + url: "https://test.local/internal/slack-event", + actor: "slack:U-INTERNAL-NONMEMBER", + body: JSON.stringify({}), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining("Invalid event"), + }); + await expect( + env.DB.prepare( + `SELECT + (SELECT COUNT(*) FROM users) AS users, + (SELECT COUNT(*) FROM user_identities) AS identities, + (SELECT COUNT(*) FROM user_role_assignments) AS assignments` + ).first<{ users: number; identities: number; assignments: number }>() + ).resolves.toEqual({ users: 0, identities: 0, assignments: 0 }); + }); + + it("creates a session for the active Member selected by a first-contact Slack email claim", async () => { + const users = new UserStore(env.DB); + const member = await users.createUser({ + displayName: "Existing Member", + email: "member@corp.test", + emailVerified: true, }); - const first = await signedFetch({ + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind("role_builtin_member", member.id) + .run(); + + const response = await signedFetch({ service: "slack-bot", method: "POST", url: "https://test.local/sessions", - actor: "slack:U-EMAIL-VIEWER", - body, + actor: "slack:U-FIRST-CONTACT-MEMBER", + body: JSON.stringify({ + title: "Readable after claim extraction", + model: "anthropic/claude-haiku-4-5", + actorEmail: "member@corp.test", + }), + }); + + expect(response.status).toBe(201); + await expect(users.getIdentity("slack", "U-FIRST-CONTACT-MEMBER")).resolves.toMatchObject({ + userId: member.id, + }); + await expect( + env.DB.prepare("SELECT title, user_id AS userId FROM sessions").first<{ + title: string; + userId: string; + }>() + ).resolves.toEqual({ title: "Readable after claim extraction", userId: member.id }); + }); + + it("denies a first-contact Linear actor whose attested email selects a suspended Member", async () => { + const users = new UserStore(env.DB); + const suspended = await users.createUser({ + displayName: "Suspended Member", + email: "suspended@corp.test", + emailVerified: true, + }); + await env.DB.batch([ + env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?").bind( + "role_builtin_member", + suspended.id + ), + env.DB.prepare("UPDATE users SET suspended_at = ? WHERE id = ?").bind(1, suspended.id), + ]); + + const response = await signedFetch({ + service: "linear-bot", + method: "POST", + url: "https://test.local/sessions", + actor: "linear:FIRST-CONTACT-SUSPENDED", + body: JSON.stringify({ + title: "Must not be created", + model: "anthropic/claude-haiku-4-5", + actorEmail: "suspended@corp.test", + }), }); - expect(first.status).toBe(409); - await expect(first.json()).resolves.toMatchObject({ code: "actor_identity_changed" }); - const identity = await new UserStore(env.DB).getIdentity("slack", "U-EMAIL-VIEWER"); - expect(identity?.userId).toBe("existing-viewer"); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code: "active_user_required" }); + await expect(users.getIdentity("linear", "FIRST-CONTACT-SUSPENDED")).resolves.toMatchObject({ + userId: suspended.id, + }); + await expect( + env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first<{ count: number }>() + ).resolves.toMatchObject({ count: 0 }); + }); + + it("denies a first-contact actor whose attested email selects an unassigned user", async () => { + const users = new UserStore(env.DB); + const unassigned = await users.createUser({ + displayName: "Unassigned User", + email: "unassigned@corp.test", + emailVerified: true, + }); + await env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?") + .bind(unassigned.id) + .run(); - const retry = await signedFetch({ + const response = await signedFetch({ service: "slack-bot", method: "POST", url: "https://test.local/sessions", - actor: "slack:U-EMAIL-VIEWER", + actor: "slack:U-FIRST-CONTACT-UNASSIGNED", + body: JSON.stringify({ + title: "Must not be created", + model: "anthropic/claude-haiku-4-5", + actorEmail: "unassigned@corp.test", + }), + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code: "assignment_required" }); + await expect(users.getIdentity("slack", "U-FIRST-CONTACT-UNASSIGNED")).resolves.toMatchObject({ + userId: unassigned.id, + }); + await expect( + env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first<{ count: number }>() + ).resolves.toMatchObject({ count: 0 }); + }); + + it.each([ + [ + "repository", + "repositories.use", + "role_session_creator_without_repo_use", + "U-FIRST-CONTACT-CONSTRAINED-REPO", + { repoOwner: "acme", repoName: "widgets" }, + ], + [ + "environment", + "environments.use", + "role_session_creator_without_environment_use", + "U-FIRST-CONTACT-CONSTRAINED-ENV", + { environmentId: "missing-environment" }, + ], + ] as const)( + "uses the first-contact canonical actor's custom-role %s target permissions", + async (_targetKind, expectedPermission, roleId, providerUserId, target) => { + const users = new UserStore(env.DB); + const constrained = await users.createUser({ + displayName: "Constrained Session Creator", + email: "constrained@corp.test", + emailVerified: true, + }); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO roles + (id, key, name, normalized_name, description, is_system) + VALUES (?, NULL, ?, ?, NULL, 0)` + ).bind( + roleId, + `Session Creator ${providerUserId}`, + `session creator ${providerUserId.toLowerCase()}` + ), + env.DB.prepare( + "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, 'sessions.create')" + ).bind(roleId), + env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?").bind( + roleId, + constrained.id + ), + ]); + + const response = await signedFetch({ + service: "slack-bot", + method: "POST", + url: "https://test.local/sessions", + actor: `slack:${providerUserId}`, + body: JSON.stringify({ + ...target, + title: "Must not use the target", + model: "anthropic/claude-haiku-4-5", + actorEmail: "constrained@corp.test", + }), + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + code: "permission_required", + permission: expectedPermission, + }); + await expect(users.getIdentity("slack", providerUserId)).resolves.toMatchObject({ + userId: constrained.id, + }); + await expect( + env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first<{ count: number }>() + ).resolves.toMatchObject({ count: 0 }); + } + ); + + it.each([ + [ + "forbidden identity fields", + "U-FIRST-CONTACT-FORBIDDEN-BODY", + JSON.stringify({ + title: "Forbidden body", + model: "anthropic/claude-haiku-4-5", + actorEmail: "body-target@corp.test", + userId: "caller-controlled-user", + }), + ], + [ + "malformed JSON", + "U-FIRST-CONTACT-MALFORMED-BODY", + '{"title":"Malformed","actorEmail":"body-target@corp.test"', + ], + ])("does not use profile claims from %s", async (_caseName, providerUserId, body) => { + const users = new UserStore(env.DB); + const bodyTarget = await users.createUser({ + displayName: "Body Target", + email: "body-target@corp.test", + emailVerified: true, + }); + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind("role_builtin_viewer", bodyTarget.id) + .run(); + + const response = await signedFetch({ + service: "slack-bot", + method: "POST", + url: "https://test.local/sessions", + actor: `slack:${providerUserId}`, body, }); - expect(retry.status).toBe(403); - await expect(retry.json()).resolves.toMatchObject({ - code: "permission_required", - permission: "sessions.create", + + expect(response.status).toBe(400); + const identity = await users.getIdentity("slack", providerUserId); + expect(identity).not.toBeNull(); + expect(identity?.userId).not.toBe(bodyTarget.id); + await expect( + env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first<{ count: number }>() + ).resolves.toMatchObject({ count: 0 }); + }); + + it("does not relink a known actor when a session body carries a conflicting email", async () => { + const users = new UserStore(env.DB); + const knownActor = await users.resolveOrCreateUser({ + provider: "slack", + providerUserId: "U-KNOWN-IMMUTABLE", + displayName: "Known Actor", + }); + const conflictingViewer = await users.createUser({ + displayName: "Conflicting Viewer", + email: "conflicting-viewer@corp.test", + emailVerified: true, + }); + await env.DB.batch([ + env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?").bind( + "role_builtin_member", + knownActor.id + ), + env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?").bind( + "role_builtin_viewer", + conflictingViewer.id + ), + ]); + + const response = await signedFetch({ + service: "slack-bot", + method: "POST", + url: "https://test.local/sessions", + actor: "slack:U-KNOWN-IMMUTABLE", + body: JSON.stringify({ + title: "Known actor remains canonical", + model: "anthropic/claude-haiku-4-5", + actorEmail: "conflicting-viewer@corp.test", + }), }); - const sessions = await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first<{ - count: number; - }>(); - expect(sessions?.count).toBe(0); + expect(response.status).toBe(201); + await expect(users.getIdentity("slack", "U-KNOWN-IMMUTABLE")).resolves.toMatchObject({ + userId: knownActor.id, + providerEmail: null, + }); + await expect( + env.DB.prepare("SELECT user_id AS userId FROM sessions").first<{ userId: string }>() + ).resolves.toEqual({ userId: knownActor.id }); + }); + + it("does not let a GitHub body email select an existing canonical user", async () => { + const users = new UserStore(env.DB); + const viewer = await users.createUser({ + displayName: "Existing Viewer", + email: "github-body-target@corp.test", + emailVerified: true, + }); + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind("role_builtin_viewer", viewer.id) + .run(); + + const response = await signedFetch({ + service: "github-bot", + method: "POST", + url: "https://test.local/sessions", + actor: "github:987654321", + body: JSON.stringify({ + title: "GitHub email is cosmetic", + model: "anthropic/claude-haiku-4-5", + actorEmail: "github-body-target@corp.test", + }), + }); + + expect(response.status).toBe(201); + const identity = await users.getIdentity("github", "987654321"); + expect(identity).toMatchObject({ providerEmail: null }); + expect(identity?.userId).not.toBe(viewer.id); + await expect( + env.DB.prepare("SELECT user_id AS userId FROM sessions").first<{ userId: string }>() + ).resolves.toEqual({ userId: identity!.userId }); }); it("requires a user or signed actor before any service can create a session", async () => { diff --git a/packages/control-plane/test/integration/worker-lifecycle-boundary.test.ts b/packages/control-plane/test/integration/worker-lifecycle-boundary.test.ts new file mode 100644 index 0000000000..12a0112df6 --- /dev/null +++ b/packages/control-plane/test/integration/worker-lifecycle-boundary.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SELF, env } from "cloudflare:test"; +import { buildServiceAuthHeaders } from "@open-inspect/shared/service-auth"; +import worker, { SessionDO } from "../../src/index"; +import type { Env } from "../../src/types"; +import { cleanD1Tables } from "./cleanup"; + +const REPOS_CACHE_KEY = "repos:list:v2"; + +function recordingExecutionContext(): { + context: ExecutionContext; + pending: Promise[]; + waitUntil: ReturnType; +} { + const pending: Promise[] = []; + const waitUntil = vi.fn((promise: Promise) => { + pending.push(promise); + }); + + return { + context: { + waitUntil, + passThroughOnException: vi.fn(), + props: {}, + } as unknown as ExecutionContext, + pending, + waitUntil, + }; +} + +function envWithSessionNamespace(sessionNamespace: object): Env { + return { + ...(env as unknown as Env), + SESSION: sessionNamespace, + } as unknown as Env; +} + +describe("composite Worker lifecycle boundary", () => { + beforeEach(async () => { + await cleanD1Tables(); + await env.REPOS_CACHE.delete(REPOS_CACHE_KEY); + }); + + afterEach(async () => { + await env.REPOS_CACHE.delete(REPOS_CACHE_KEY); + }); + + it("exports the entrypoints required by the Cloudflare deployment", () => { + expect(SessionDO).toBeTypeOf("function"); + expect(worker.fetch).toBeTypeOf("function"); + expect(worker.scheduled).toBeTypeOf("function"); + expect(worker.queue).toBeTypeOf("function"); + }); + + it("serves ordinary HTTP requests through the default Worker fetch entrypoint", async () => { + const { context } = recordingExecutionContext(); + + const response = await worker.fetch( + new Request("https://test.local/health"), + env as unknown as Env, + context + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + status: "healthy", + service: "open-inspect-control-plane", + }); + }); + + it("rejects an upgrade on an invalid WebSocket path before Durable Object dispatch", async () => { + const sessionNamespace = { + idFromName: vi.fn(() => { + throw new Error("invalid WebSocket paths must not allocate a Durable Object ID"); + }), + get: vi.fn(() => { + throw new Error("invalid WebSocket paths must not obtain a Durable Object stub"); + }), + }; + const { context } = recordingExecutionContext(); + + const response = await worker.fetch( + new Request("https://test.local/not-a-session-websocket", { + headers: { Upgrade: "websocket" }, + }), + envWithSessionNamespace(sessionNamespace), + context + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe("Invalid WebSocket path"); + expect(sessionNamespace.idFromName).not.toHaveBeenCalled(); + expect(sessionNamespace.get).not.toHaveBeenCalled(); + }); + + it("returns 404 for a missing WebSocket session before Durable Object dispatch", async () => { + const sessionNamespace = { + idFromName: vi.fn(() => { + throw new Error("missing sessions must not allocate a Durable Object ID"); + }), + get: vi.fn(() => { + throw new Error("missing sessions must not obtain a Durable Object stub"); + }), + }; + const { context } = recordingExecutionContext(); + + const response = await worker.fetch( + new Request("https://test.local/sessions/missing-session/ws", { + headers: { Upgrade: "websocket" }, + }), + envWithSessionNamespace(sessionNamespace), + context + ); + + expect(response.status).toBe(404); + await expect(response.text()).resolves.toBe("Session not found"); + expect(sessionNamespace.idFromName).not.toHaveBeenCalled(); + expect(sessionNamespace.get).not.toHaveBeenCalled(); + }); + + it("routes a non-upgrade WebSocket path through ordinary HTTP dispatch", async () => { + const response = await SELF.fetch("https://test.local/sessions/missing-session/ws"); + + expect(response.status).toBe(404); + expect(response.headers.get("Content-Type")).toBe("application/json"); + await expect(response.json()).resolves.toEqual({ error: "Not found" }); + }); + + it("keeps route background work on the original fetch execution context", async () => { + await env.REPOS_CACHE.put( + REPOS_CACHE_KEY, + JSON.stringify({ repos: [], cachedAt: new Date(0).toISOString(), freshUntil: 0 }) + ); + const url = "https://test.local/repos"; + const authHeaders = await buildServiceAuthHeaders({ + service: "slack-bot", + secret: "test-service-secret-slack-bot", + method: "GET", + url, + }); + const { context, pending, waitUntil } = recordingExecutionContext(); + + const response = await worker.fetch( + new Request(url, { headers: authHeaders }), + env as unknown as Env, + context + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ cached: true, repos: [] }); + expect(waitUntil).toHaveBeenCalledTimes(1); + expect(pending).toHaveLength(1); + await Promise.allSettled(pending); + }); +}); diff --git a/packages/shared/src/types/session-api.ts b/packages/shared/src/types/session-api.ts index dbf5952870..8805f61e9c 100644 --- a/packages/shared/src/types/session-api.ts +++ b/packages/shared/src/types/session-api.ts @@ -254,9 +254,10 @@ export type CreateSessionRequest = z.infer; export const createSessionInputSchema = createSessionRequestBaseSchema .extend({ - // Display-only identity fields. Callers may not assert identity or SCM - // credentials in the body — identity derives from the verified principal - // and the control plane rejects forbidden identity fields. + // Profile fields accompany the identity asserted by a verified principal; + // callers may not assert provider/user IDs or SCM credentials. The + // control plane treats actorEmail as identity-bearing only when an + // email-attesting Slack/Linear service signs this exact request body. scmLogin: z.string().optional(), scmName: z.string().optional(), scmEmail: z.string().optional(), From 8d11c2885db4fd111c8dc62804162b8f0ead7b6d Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 2 Sep 2026 09:14:04 -0700 Subject: [PATCH 2/3] fix: address routing review feedback - stop admission before any identity write when the route rejects the body; the claims hook now returns an accepted or rejected result - deny a principal-less request unless the route declares no authorization, and reject that pairing when the Hono app is built - capture request start before Hono route selection - prove admission per credential class on a policy-preserving shadow catalog with sentinel handlers - recreate the automation fixture per mutating route and clean D1 after the matrix suite - share the request-context and common-header construction - keep WebSocket upgrades outside the Hono box in the README diagram Claude-Session: https://claude.ai/code/session_01KdDpTgGEjXpBA9SaGQVUH1 --- packages/control-plane/README.md | 7 +- .../src/routes/session-create.ts | 19 +- packages/control-plane/src/routes/shared.ts | 18 +- .../control-plane/src/routing/hono-app.ts | 64 +++--- .../src/routing/request-lifecycle.ts | 29 +-- .../src/routing/route-admission.ts | 13 +- .../route-admission-matrix.test.ts.snap | 10 +- .../control-plane/test/integration/helpers.ts | 44 +++-- .../route-admission-matrix.test.ts | 184 +++++++++++++++++- .../test/integration/service-auth.test.ts | 16 +- 10 files changed, 314 insertions(+), 90 deletions(-) diff --git a/packages/control-plane/README.md b/packages/control-plane/README.md index 1f05c1ecda..c77f15e8e9 100644 --- a/packages/control-plane/README.md +++ b/packages/control-plane/README.md @@ -22,8 +22,11 @@ The control plane provides: ┌─────────────────────────────────────────────────────────────────┐ │ Cloudflare Workers │ │ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Hono HTTP API + Route Admission │ │ -│ │ POST /sessions │ GET /sessions/:id │ WebSocket* │ │ +│ │ Worker fetch entrypoint │ │ +│ │ ┌──────────────────────────────────┐ ┌───────────────┐ │ │ +│ │ │ Hono HTTP API + Route Admission │ │ WebSocket │ │ │ +│ │ │ POST /sessions GET /sessions/:id│ │ upgrade* │ │ │ +│ │ └──────────────────────────────────┘ └───────────────┘ │ │ │ └─────────────────────────────┬────────────────────────────┘ │ │ │ │ │ ┌─────────────────────────────┴────────────────────────────┐ │ diff --git a/packages/control-plane/src/routes/session-create.ts b/packages/control-plane/src/routes/session-create.ts index 07d6bdf3f8..b746dbf240 100644 --- a/packages/control-plane/src/routes/session-create.ts +++ b/packages/control-plane/src/routes/session-create.ts @@ -34,7 +34,7 @@ import { GITHUB_USER_OR_SERVICE_ROUTE, defineRoutes, requirePermission, - type ServiceActorProfileClaims, + type ServiceActorClaimsResult, } from "./shared"; const logger = createLogger("router:session-create"); @@ -46,19 +46,22 @@ const BRANCH_NAME_PATTERN = /^[\w.\-/]+$/; async function extractSessionActorProfileClaims( request: Request, ctx: RequestContext -): Promise { +): Promise { const parsed = await parseCreateSessionInput(request); - if (!parsed.ok) return null; + if (!parsed.ok) return { kind: "rejected", response: error(parsed.message, 400) }; // Keep the admission-time claim view aligned with the handler's raw-body - // identity guard. Invalid input remains handler-owned and yields no claims. + // identity guard; the same rejection ends admission before enrollment. const enforcement = applyIdentityEnforcement(ctx, "session-create", parsed.raw); - if (enforcement.rejection) return null; + if (enforcement.rejection) return { kind: "rejected", response: enforcement.rejection }; return { - displayName: parsed.input.actorDisplayName, - email: parsed.input.actorEmail, - avatarUrl: parsed.input.actorAvatarUrl, + kind: "claims", + claims: { + displayName: parsed.input.actorDisplayName, + email: parsed.input.actorEmail, + avatarUrl: parsed.input.actorAvatarUrl, + }, }; } diff --git a/packages/control-plane/src/routes/shared.ts b/packages/control-plane/src/routes/shared.ts index da4b83dbe2..52384896aa 100644 --- a/packages/control-plane/src/routes/shared.ts +++ b/packages/control-plane/src/routes/shared.ts @@ -28,6 +28,15 @@ export interface ServiceActorProfileClaims { avatarUrl?: string; } +/** + * Outcome of preparing a route's actor claims before identity is finalized. + * A rejected body ends admission with the route's own response, so no user, + * identity, or assignment is written for a request the handler would refuse. + */ +export type ServiceActorClaimsResult = + | { kind: "claims"; claims: ServiceActorProfileClaims } + | { kind: "rejected"; response: Response }; + /** Route matching, authorization, and handler configuration. */ export interface RouteDefinition { method: string; @@ -37,13 +46,10 @@ export interface RouteDefinition Promise; + serviceActorClaims?: (request: Request, ctx: RequestContext) => Promise; cacheControl?: "no-store" | "private, no-store"; handler: (request: Request, env: Env, match: RegExpMatchArray, ctx: Context) => Promise; } diff --git a/packages/control-plane/src/routing/hono-app.ts b/packages/control-plane/src/routing/hono-app.ts index ba614dd376..c31e4fc81b 100644 --- a/packages/control-plane/src/routing/hono-app.ts +++ b/packages/control-plane/src/routing/hono-app.ts @@ -37,12 +37,39 @@ const logger = createLogger("router"); */ const ROUTE_PATH_GRAMMAR = /^(\/([A-Za-z0-9_-]+|:\w+))+$/; -function createHonoApp(catalog: readonly Route[]): Hono { - for (const route of catalog) { - if (!ROUTE_PATH_GRAMMAR.test(route.path)) { - throw new Error(`Route path is outside the supported grammar: ${route.method} ${route.path}`); - } +/** Wall-clock start captured before Hono selects a route, keyed by the raw request. */ +const requestStartedAt = new WeakMap(); + +function assertRouteContract(route: Route): void { + if (!ROUTE_PATH_GRAMMAR.test(route.path)) { + throw new Error(`Route path is outside the supported grammar: ${route.method} ${route.path}`); } + const principalless = + route.authentication.kind === "public" || route.authentication.kind === "handler-authenticated"; + if (principalless && route.authorization.kind !== "none") { + throw new Error( + `Route without a verified principal cannot require authorization: ${route.method} ${route.path}` + ); + } +} + +function contextFor( + request: Request, + env: Env, + executionCtx: Parameters[0] +): RequestContext { + // eslint-disable-next-line no-restricted-syntax -- ordinary HTTP composition root passes the stable binding once + const database = env.DB; + return createRequestContext({ + request, + env, + database, + executionCtx: createCloudflareBackgroundTasks(executionCtx), + }); +} + +function createHonoApp(catalog: readonly Route[]): Hono { + for (const route of catalog) assertRouteContract(route); const app = new Hono({ strict: true, @@ -57,17 +84,8 @@ function createHonoApp(catalog: readonly Route[]): Hono { app.use("*", async (c, next) => { // TrieRouter runs a root wildcard twice for the literal path `/*`. if (c.get("requestContext")) return next(); - const startedAt = Date.now(); - // eslint-disable-next-line no-restricted-syntax -- Hono composition root passes the stable binding once - const database = c.env.DB; - const context = createRequestContext({ - request: c.req.raw, - env: c.env, - database, - executionCtx: createCloudflareBackgroundTasks(c.executionCtx), - }); - c.set("requestContext", context); - c.set("startedAt", startedAt); + c.set("requestContext", contextFor(c.req.raw, c.env, c.executionCtx)); + c.set("startedAt", requestStartedAt.get(c.req.raw) ?? Date.now()); await next(); }); @@ -130,6 +148,7 @@ export function createControlPlaneHttpHandler(catalog: readonly Route[]): Contro const app = createHonoApp(catalog); return async (request, env, executionCtx) => { + requestStartedAt.set(request, Date.now()); const pathname = new URL(request.url).pathname; // eslint-disable-next-line no-restricted-syntax -- ordinary HTTP composition root validates the required binding @@ -142,15 +161,10 @@ export function createControlPlaneHttpHandler(catalog: readonly Route[]): Contro } if (request.method === "HEAD") { - // eslint-disable-next-line no-restricted-syntax -- ordinary HTTP composition root passes the stable binding once - const database = env.DB; - const context = createRequestContext({ - request, - env, - database, - executionCtx: createCloudflareBackgroundTasks(executionCtx), - }); - return withCorsAndTraceHeaders(error("Not found", 404), context); + return withCorsAndTraceHeaders( + error("Not found", 404), + contextFor(request, env, executionCtx) + ); } return app.fetch(request, env, executionCtx); diff --git a/packages/control-plane/src/routing/request-lifecycle.ts b/packages/control-plane/src/routing/request-lifecycle.ts index 70a1aa8226..ee0e956758 100644 --- a/packages/control-plane/src/routing/request-lifecycle.ts +++ b/packages/control-plane/src/routing/request-lifecycle.ts @@ -5,12 +5,17 @@ import type { Route } from "../routes/shared"; const logger = createLogger("router"); -/** Add the response headers shared by all ordinary HTTP route responses. */ -export function withCorsAndTraceHeaders(response: Response, ctx: RequestContext): Response { +/** Rebuild a response once with the common headers plus any route-owned overrides. */ +function withCommonHeaders( + response: Response, + ctx: RequestContext, + overrides?: Record +): Response { const headers = new Headers(response.headers); headers.set("Access-Control-Allow-Origin", "*"); headers.set("x-request-id", ctx.request_id); headers.set("x-trace-id", ctx.trace_id); + for (const [name, value] of Object.entries(overrides ?? {})) headers.set(name, value); return new Response(response.body, { status: response.status, statusText: response.statusText, @@ -18,22 +23,22 @@ export function withCorsAndTraceHeaders(response: Response, ctx: RequestContext) }); } +/** Add the response headers shared by all ordinary HTTP route responses. */ +export function withCorsAndTraceHeaders(response: Response, ctx: RequestContext): Response { + return withCommonHeaders(response, ctx); +} + /** Apply all matched-route response policy in one body-preserving reconstruction. */ export function finalizeRouteResponse( response: Response, route: Route, ctx: RequestContext ): Response { - const headers = new Headers(response.headers); - headers.set("Access-Control-Allow-Origin", "*"); - headers.set("x-request-id", ctx.request_id); - headers.set("x-trace-id", ctx.trace_id); - if (route.cacheControl) headers.set("Cache-Control", route.cacheControl); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); + return withCommonHeaders( + response, + ctx, + route.cacheControl ? { "Cache-Control": route.cacheControl } : undefined + ); } /** Emit verified principal attribution without credential material. */ diff --git a/packages/control-plane/src/routing/route-admission.ts b/packages/control-plane/src/routing/route-admission.ts index 9eea75d457..605312f9e1 100644 --- a/packages/control-plane/src/routing/route-admission.ts +++ b/packages/control-plane/src/routing/route-admission.ts @@ -360,9 +360,12 @@ async function finalizeServiceActor( } try { - const claims = policy.serviceActorClaims + const prepared = policy.serviceActorClaims ? await policy.serviceActorClaims(request.clone(), ctx) : null; + // The route refused this body: answer with its response and write nothing. + if (prepared?.kind === "rejected") return { response: prepared.response }; + const claims = prepared?.claims; const actor = principal.actor; const user = await new UserStore(ctx.db).resolveOrCreateUser({ provider: actor.provider, @@ -643,7 +646,13 @@ async function enforceRouteAuthorization( ): Promise { const evidence = emptyEvidence(); const principal = ctx.principal; - if (!principal) return allowed(policy, "user", evidence); + if (!principal) { + // Only a route that declares no authorization may run without a subject. + if (policy.authorization.kind !== "none") { + return { kind: "error", response: error("Unauthorized", 401) }; + } + return allowed(policy, "user", evidence); + } const principalFailure = enforceRoutePrincipal(policy.authentication, principal, evidence); if (principalFailure) return resultForFailure(principalFailure); diff --git a/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap b/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap index ebe436040a..5069cd4cd7 100644 --- a/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap +++ b/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap @@ -146,12 +146,12 @@ exports[`route admission matrix > admits the workspace owner through every brows "GET /automations/:id owner=200", "PUT /automations/:id owner=200", "DELETE /automations/:id owner=200", - "POST /automations/:id/pause owner=404", - "POST /automations/:id/resume owner=404", - "POST /automations/:id/trigger owner=404", - "GET /automations/:id/invocations owner=404", + "POST /automations/:id/pause owner=200", + "POST /automations/:id/resume owner=200", + "POST /automations/:id/trigger owner=201", + "GET /automations/:id/invocations owner=200", "GET /automations/:id/runs/:runId owner=404", - "POST /automations/:id/regenerate-key owner=404", + "POST /automations/:id/regenerate-key owner=400", "GET /mcp-servers owner=200", "POST /mcp-servers owner=400", "GET /mcp-servers/:id owner=404", diff --git a/packages/control-plane/test/integration/helpers.ts b/packages/control-plane/test/integration/helpers.ts index 3f25f015c8..60d59ddbc0 100644 --- a/packages/control-plane/test/integration/helpers.ts +++ b/packages/control-plane/test/integration/helpers.ts @@ -152,17 +152,23 @@ async function testBrowserSessionCookie(initialRole: InitialUserRole): Promise; + service?: ServiceName; + actor?: string; + initialUserRole?: InitialUserRole; +} + +/** + * Build the production-equivalent credential headers for one request: sig1 + * for the service plus, for web, the seeded Better Auth browser session. + */ +export async function serviceRequestHeaders( url: string, - init?: { - method?: string; - body?: string; - headers?: Record; - service?: ServiceName; - actor?: string; - initialUserRole?: InitialUserRole; - } -): Promise { + init?: ServiceRequestInit +): Promise> { const method = init?.method ?? "GET"; const service = init?.service ?? "web"; const auth = await buildServiceAuthHeaders({ @@ -177,14 +183,18 @@ export async function serviceFetch( service === "web" ? await testBrowserSessionCookie(init?.initialUserRole ?? DEFAULT_INITIAL_USER_ROLE) : undefined; + return { + ...(init?.body === undefined ? {} : { "Content-Type": "application/json" }), + ...(browserCookie ? { Cookie: browserCookie } : {}), + ...init?.headers, + ...auth, + }; +} + +export async function serviceFetch(url: string, init?: ServiceRequestInit): Promise { return SELF.fetch(url, { - method, - headers: { - ...(init?.body === undefined ? {} : { "Content-Type": "application/json" }), - ...(browserCookie ? { Cookie: browserCookie } : {}), - ...init?.headers, - ...auth, - }, + method: init?.method ?? "GET", + headers: await serviceRequestHeaders(url, init), body: init?.body, }); } diff --git a/packages/control-plane/test/integration/route-admission-matrix.test.ts b/packages/control-plane/test/integration/route-admission-matrix.test.ts index f3151d3d26..6331ec2a88 100644 --- a/packages/control-plane/test/integration/route-admission-matrix.test.ts +++ b/packages/control-plane/test/integration/route-admission-matrix.test.ts @@ -9,12 +9,22 @@ */ import { SELF, env } from "cloudflare:test"; -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildServiceAuthHeaders } from "@open-inspect/shared/service-auth"; +import { createExecutionContext } from "cloudflare:test"; +import { createControlPlaneHttpHandler } from "../../src/routing/hono-app"; +import type { Env } from "../../src/types"; import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; import { routes } from "../../src/routes/catalog"; import type { Route } from "../../src/routes/shared"; import { cleanD1Tables } from "./cleanup"; -import { initSession, seedSandboxAuth, serviceFetch, waitForSandboxStatus } from "./helpers"; +import { + initSession, + seedSandboxAuth, + serviceFetch, + serviceRequestHeaders, + waitForSandboxStatus, +} from "./helpers"; const BASE = "https://test.local"; const BROWSER_USER_ID = "11111111111111111111111111111111"; @@ -74,6 +84,17 @@ function isSessionRoute(route: Route): boolean { return route.path.startsWith("/sessions/:id"); } +function isAutomationRoute(route: Route): boolean { + return route.path.startsWith("/automations/:id"); +} + +let automationSequence = 0; +async function createAutomation(): Promise { + const id = `matrix-automation-${automationSequence++}`; + await new AutomationStore(env.DB).create(automation(id, BROWSER_USER_ID)); + return id; +} + function isMutation(route: Route): boolean { return route.method !== "GET"; } @@ -109,8 +130,11 @@ describe("route admission matrix", { timeout: MATRIX_TIMEOUT_MS }, () => { await seedSandboxAuth(stub, { authToken: SANDBOX_TOKEN, sandboxId: "sb-matrix" }); fixtures.sandboxSessionId = sessionName; - fixtures.automationId = "matrix-automation"; - await new AutomationStore(env.DB).create(automation(fixtures.automationId, BROWSER_USER_ID)); + fixtures.automationId = await createAutomation(); + }, MATRIX_TIMEOUT_MS); + + afterAll(async () => { + await cleanD1Tables(); }, MATRIX_TIMEOUT_MS); it("rejects every credentialed route anonymously by its authentication class", async () => { @@ -149,11 +173,16 @@ describe("route admission matrix", { timeout: MATRIX_TIMEOUT_MS }, () => { if (kind === "sandbox" || kind === "service" || kind === "public") continue; if (kind === "handler-authenticated") continue; - const sessionId = - isSessionRoute(route) && isMutation(route) + // Mutating routes get a fresh resource so an earlier DELETE or state + // change cannot turn later routes into handler-owned 404s. + const id = isAutomationRoute(route) + ? isMutation(route) + ? await createAutomation() + : fixtures.automationId + : isSessionRoute(route) && isMutation(route) ? await createReadySession() : fixtures.readonlySessionId; - const url = `${BASE}${materialize(route, { id: route.path.startsWith("/automations/") ? fixtures.automationId : sessionId })}`; + const url = `${BASE}${materialize(route, { id })}`; const response = await serviceFetch(url, { method: route.method, ...(isMutation(route) ? { body: "{}" } : {}), @@ -267,3 +296,144 @@ describe("route admission matrix", { timeout: MATRIX_TIMEOUT_MS }, () => { expect(observed).toMatchSnapshot(); }); }); + +/** + * Admission proof independent of handler behavior: every production policy is + * kept, every handler is replaced by a sentinel, and each credential class is + * asserted to reach the sentinel exactly when the route's policy admits it. + */ +describe("route admission sentinel", { timeout: MATRIX_TIMEOUT_MS }, () => { + const fixtures: MatrixFixtures = { + readonlySessionId: "", + sandboxSessionId: "", + automationId: "", + }; + const shadow: Route[] = routes.map((route) => ({ + ...route, + handler: async () => Response.json({ sentinel: `${route.method} ${route.path}` }), + })); + const handle = createControlPlaneHttpHandler(shadow); + + beforeAll(async () => { + await cleanD1Tables(); + expect((await serviceFetch(`${BASE}/me/authorization`)).status).toBe(200); + fixtures.readonlySessionId = await createReadySession(); + const { stub, sessionName } = await initSession({ userId: BROWSER_USER_ID }); + await seedSandboxAuth(stub, { authToken: SANDBOX_TOKEN, sandboxId: "sb-sentinel" }); + fixtures.sandboxSessionId = sessionName; + fixtures.automationId = await createAutomation(); + }, MATRIX_TIMEOUT_MS); + + afterAll(async () => { + await cleanD1Tables(); + }, MATRIX_TIMEOUT_MS); + + async function reachedSentinel(response: Response, identity: string): Promise { + if (response.status !== 200) return false; + const body = (await response.json().catch(() => null)) as { sentinel?: string } | null; + return body?.sentinel === identity; + } + + async function botHeaders( + url: string, + method: string, + service: (typeof BOT_SERVICES)[number], + actor?: string + ): Promise> { + return buildServiceAuthHeaders({ + service, + secret: `test-service-secret-${service}`, + method, + url, + actor, + }); + } + + function send(url: string, method: string, headers: Record): Promise { + return handle( + new Request(url, { method, headers }), + env as unknown as Env, + createExecutionContext() + ); + } + + it("admits exactly the credential classes each route's policy accepts", async () => { + for (const route of routes) { + const identity = `${route.method} ${route.path}`; + const kind = route.authentication.kind; + const sessionId = + kind === "sandbox" || kind === "user-or-service-with-sandbox-fallback" + ? fixtures.sandboxSessionId + : fixtures.readonlySessionId; + const url = `${BASE}${materialize(route, { + id: isAutomationRoute(route) ? fixtures.automationId : sessionId, + })}`; + const method = route.method; + const expectReach = async ( + headers: Record, + label: string, + reach: boolean + ) => { + const response = await send(url, method, headers); + expect(await reachedSentinel(response, identity), `${identity} [${label}]`).toBe(reach); + }; + + const owner = await serviceRequestHeaders(url, { method }); + const sandbox = { Authorization: `Bearer ${SANDBOX_TOKEN}` }; + const wrongSandbox = { Authorization: "Bearer not-the-sandbox-token" }; + const actorBot = await botHeaders(url, method, "slack-bot", "slack:U-SENTINEL"); + + switch (kind) { + case "public": + await expectReach({}, "anonymous", true); + break; + case "handler-authenticated": + // The handler owns credential verification, so admission is open. + await expectReach({}, "anonymous", true); + break; + case "web-service": + await expectReach(owner, "web", true); + await expectReach({}, "anonymous", false); + await expectReach(actorBot, "bot", false); + break; + case "user": + await expectReach(owner, "owner", true); + await expectReach({}, "anonymous", false); + await expectReach(actorBot, "bot actor", false); + break; + case "user-or-service": + await expectReach(owner, "owner", true); + await expectReach({}, "anonymous", false); + await expectReach(wrongSandbox, "bearer", false); + break; + case "service": { + if (route.authorization.kind !== "service") throw new Error(identity); + const services: readonly string[] = route.authorization.services; + const denied = BOT_SERVICES.find((service) => !services.includes(service)); + if (!denied) throw new Error(`${identity} admits every bot service`); + await expectReach( + await botHeaders(url, method, route.authorization.services[0]), + "bot", + true + ); + await expectReach(await botHeaders(url, method, denied), "wrong bot", false); + await expectReach(owner, "web", false); + await expectReach({}, "anonymous", false); + break; + } + case "sandbox": + await expectReach(sandbox, "sandbox", true); + await expectReach(wrongSandbox, "wrong token", false); + await expectReach(owner, "owner", false); + await expectReach({}, "anonymous", false); + break; + case "user-or-service-with-sandbox-fallback": + await expectReach(sandbox, "sandbox", true); + await expectReach(owner, "owner", true); + await expectReach(wrongSandbox, "wrong token", false); + await expectReach({}, "anonymous", false); + break; + } + } + }); +}); diff --git a/packages/control-plane/test/integration/service-auth.test.ts b/packages/control-plane/test/integration/service-auth.test.ts index 8b4fe390f6..e46a56c7c7 100644 --- a/packages/control-plane/test/integration/service-auth.test.ts +++ b/packages/control-plane/test/integration/service-auth.test.ts @@ -720,7 +720,7 @@ describe("sig1 service-credential authentication", () => { "U-FIRST-CONTACT-MALFORMED-BODY", '{"title":"Malformed","actorEmail":"body-target@corp.test"', ], - ])("does not use profile claims from %s", async (_caseName, providerUserId, body) => { + ])("rejects %s before enrolling the actor", async (_caseName, providerUserId, body) => { const users = new UserStore(env.DB); const bodyTarget = await users.createUser({ displayName: "Body Target", @@ -740,12 +740,16 @@ describe("sig1 service-credential authentication", () => { }); expect(response.status).toBe(400); - const identity = await users.getIdentity("slack", providerUserId); - expect(identity).not.toBeNull(); - expect(identity?.userId).not.toBe(bodyTarget.id); + await expect(users.getIdentity("slack", providerUserId)).resolves.toBeNull(); await expect( - env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first<{ count: number }>() - ).resolves.toMatchObject({ count: 0 }); + env.DB.prepare( + `SELECT + (SELECT COUNT(*) FROM sessions) AS sessions, + (SELECT COUNT(*) FROM users) AS users, + (SELECT COUNT(*) FROM user_identities) AS identities` + ).first<{ sessions: number; users: number; identities: number }>() + ).resolves.toEqual({ sessions: 0, users: 1, identities: 0 }); + void bodyTarget; }); it("does not relink a known actor when a session body carries a conflicting email", async () => { From 8f0c6f04fdd1dcbbd21aac54c4792f469c680623 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 2 Sep 2026 10:01:17 -0700 Subject: [PATCH 3/3] fix: refuse unsupported SCM providers before enrolling a service actor - run the deployment provider gate inside actor finalization so a request the deployment cannot serve writes no user, identity, or assignment; the final gate keeps its legacy position for everyone else - define the test helper's default request method once Claude-Session: https://claude.ai/code/session_01KdDpTgGEjXpBA9SaGQVUH1 --- .../src/routing/route-admission.ts | 36 ++++++++++++++----- .../control-plane/test/integration/helpers.ts | 6 ++-- .../response-compatibility.test.ts | 22 ++++++++++++ 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/packages/control-plane/src/routing/route-admission.ts b/packages/control-plane/src/routing/route-admission.ts index 605312f9e1..1253e5a55b 100644 --- a/packages/control-plane/src/routing/route-admission.ts +++ b/packages/control-plane/src/routing/route-admission.ts @@ -49,6 +49,8 @@ export type RouteAdmissionResult = export interface AuthorizationFailure { response: Response; decision?: DeniedAuthorizationDecision; + /** Deployment-capability refusals skip the general request log, as at the final gate. */ + requestLog?: "emit" | "skip"; } interface AuthorizationEvidence { @@ -59,7 +61,7 @@ interface AuthorizationEvidence { type RouteAuthorizationResult = | { kind: "allowed"; decision: AllowedAuthorizationDecision } | { kind: "denied"; response: Response; decision: DeniedAuthorizationDecision } - | { kind: "error"; response: Response }; + | { kind: "error"; response: Response; requestLog?: "emit" | "skip" }; function denied( response: Response, @@ -109,7 +111,7 @@ function resultForFailure( ): Exclude { return failure.decision ? { kind: "denied", response: failure.response, decision: failure.decision } - : { kind: "error", response: failure.response }; + : { kind: "error", response: failure.response, requestLog: failure.requestLog }; } function enforceImplementedScmProvider( @@ -351,6 +353,8 @@ function enforceStaticServicePermissionCeiling( async function finalizeServiceActor( policy: RouteAdmissionPolicy, request: Request, + pathname: string, + env: Env, ctx: RequestContext ): Promise { if (!loadsCanonicalSubject(policy)) return null; @@ -359,6 +363,11 @@ async function finalizeServiceActor( return null; } + // Deployment capability does not depend on the caller: a request this + // deployment cannot serve must not enroll a user, identity, or assignment. + const providerCheck = enforceImplementedScmProvider(policy, pathname, env, ctx); + if (providerCheck) return { response: providerCheck, requestLog: "skip" }; + try { const prepared = policy.serviceActorClaims ? await policy.serviceActorClaims(request.clone(), ctx) @@ -642,6 +651,8 @@ async function enforceRouteAuthorization( policy: RouteAdmissionPolicy, match: RegExpMatchArray, request: Request, + pathname: string, + env: Env, ctx: RequestContext ): Promise { const evidence = emptyEvidence(); @@ -671,7 +682,7 @@ async function enforceRouteAuthorization( const ceilingFailure = enforceStaticServicePermissionCeiling(policy, ctx, evidence); if (ceilingFailure) return resultForFailure(ceilingFailure); - const actorFailure = await finalizeServiceActor(policy, request, ctx); + const actorFailure = await finalizeServiceActor(policy, request, pathname, env, ctx); if (actorFailure) return resultForFailure(actorFailure); const activeUserFailure = await enforceActiveUser(policy, ctx, evidence); @@ -764,12 +775,19 @@ export async function admitRoute(input: { } } - const authorization = await enforceRouteAuthorization(policy, match, handlerRequest, ctx); - if (authorization.kind !== "allowed") { - return denied( - authorization.response, - authorization.kind === "denied" ? { decision: authorization.decision } : undefined - ); + const authorization = await enforceRouteAuthorization( + policy, + match, + handlerRequest, + pathname, + env, + ctx + ); + if (authorization.kind === "denied") { + return denied(authorization.response, { decision: authorization.decision }); + } + if (authorization.kind === "error") { + return denied(authorization.response, { requestLog: authorization.requestLog }); } const providerCheck = enforceImplementedScmProvider(policy, pathname, env, ctx); diff --git a/packages/control-plane/test/integration/helpers.ts b/packages/control-plane/test/integration/helpers.ts index 60d59ddbc0..c912ccc182 100644 --- a/packages/control-plane/test/integration/helpers.ts +++ b/packages/control-plane/test/integration/helpers.ts @@ -152,6 +152,8 @@ async function testBrowserSessionCookie(initialRole: InitialUserRole): Promise> { - const method = init?.method ?? "GET"; + const method = init?.method ?? DEFAULT_SERVICE_REQUEST_METHOD; const service = init?.service ?? "web"; const auth = await buildServiceAuthHeaders({ service, @@ -193,7 +195,7 @@ export async function serviceRequestHeaders( export async function serviceFetch(url: string, init?: ServiceRequestInit): Promise { return SELF.fetch(url, { - method: init?.method ?? "GET", + method: init?.method ?? DEFAULT_SERVICE_REQUEST_METHOD, headers: await serviceRequestHeaders(url, init), body: init?.body, }); diff --git a/packages/control-plane/test/integration/response-compatibility.test.ts b/packages/control-plane/test/integration/response-compatibility.test.ts index d833edc964..ae5711b98f 100644 --- a/packages/control-plane/test/integration/response-compatibility.test.ts +++ b/packages/control-plane/test/integration/response-compatibility.test.ts @@ -151,6 +151,28 @@ describe("ordinary HTTP response compatibility", () => { expect(response.headers.get("Cache-Control")).toBeNull(); }); + it("refuses an unsupported provider before enrolling a verified service actor", async () => { + const request = await signedRequest({ + url: "https://test.local/sessions", + method: "POST", + service: "slack-bot", + actor: "slack:U-UNSUPPORTED-PROVIDER", + body: JSON.stringify({ title: "Never created", model: "anthropic/claude-haiku-4-5" }), + }); + + const response = await fetchWorker(request, { ...env, SCM_PROVIDER: "gitlab" } as Env); + + expect(response.status).toBe(501); + await expect( + env.DB.prepare( + `SELECT + (SELECT COUNT(*) FROM users) AS users, + (SELECT COUNT(*) FROM user_identities) AS identities, + (SELECT COUNT(*) FROM user_role_assignments) AS assignments` + ).first<{ users: number; identities: number; assignments: number }>() + ).resolves.toEqual({ users: 0, identities: 0, assignments: 0 }); + }); + it("applies a matched route's cache policy to an authentication rejection", async () => { const traceId = "route-cache-trace"; const response = await SELF.fetch("https://test.local/roles", {