Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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