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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 13 additions & 3 deletions packages/control-plane/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -21,8 +22,11 @@ The control plane provides:
┌─────────────────────────────────────────────────────────────────┐
│ Cloudflare Workers │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ API Gateway (router.ts) │ │
│ │ POST /sessions │ GET /sessions/:id │ WebSocket │ │
│ │ Worker fetch entrypoint │ │
│ │ ┌──────────────────────────────────┐ ┌───────────────┐ │ │
│ │ │ Hono HTTP API + Route Admission │ │ WebSocket │ │ │
│ │ │ POST /sessions GET /sessions/:id│ │ upgrade* │ │ │
│ │ └──────────────────────────────────┘ └───────────────┘ │ │
│ └─────────────────────────────┬────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────┴────────────────────────────┐ │
Expand All @@ -45,6 +49,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
Expand Down
1 change: 1 addition & 0 deletions packages/control-plane/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
4 changes: 2 additions & 2 deletions packages/control-plane/src/auth/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -34,7 +34,7 @@ export interface AuthenticationRequirement {
export async function authenticate(
request: Request,
env: Env,
ctx: RequestContext,
ctx: AuthenticationRequestServices,
requirement: AuthenticationRequirement = {}
): Promise<AuthResult> {
const signatureHeader = request.headers.get(SERVICE_SIGNATURE_HEADER);
Expand Down
163 changes: 28 additions & 135 deletions packages/control-plane/src/auth/identity-enforcement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -43,12 +42,6 @@ function createCtx(principal?: Principal): RequestContext {
} as unknown as RequestContext;
}

function loggedEvents(spy: { mock: { calls: unknown[][] } }): Array<Record<string, unknown>> {
return spy.mock.calls.map(
([message]: unknown[]) => JSON.parse(String(message)) as Record<string, unknown>
);
}

afterEach(() => {
vi.restoreAllMocks();
});
Expand Down Expand Up @@ -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",
});
});
});

Expand Down
14 changes: 14 additions & 0 deletions packages/control-plane/src/auth/request-services.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 7 additions & 3 deletions packages/control-plane/src/auth/service/request-authenticator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -43,7 +43,11 @@ function parseActor(actor: string): { provider: ActorNamespace; providerUserId:
const seenNonces = new Map<string, number>();
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);
Expand Down Expand Up @@ -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<AuthResult> {
const serviceHeader = request.headers.get(SERVICE_HEADER) ?? "";
Expand Down
28 changes: 28 additions & 0 deletions packages/control-plane/src/http/create-request-context.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
27 changes: 27 additions & 0 deletions packages/control-plane/src/http/request-context.ts
Original file line number Diff line number Diff line change
@@ -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;
};
26 changes: 26 additions & 0 deletions packages/control-plane/src/http/responses.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}
Loading
Loading