Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ jobs:
- name: Test complexity reporter
run: npm run test:lint-complexity

- name: Test Owner bootstrap CLI
run: npm run test:rbac-bootstrap-owner

- name: Check Prettier formatting
run: npm run format:check

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
"format:check": "prettier --check .",
"test": "npm run test --workspaces --if-present",
"test:lint-complexity": "node --test scripts/lint-complexity-message.test.mjs",
"test:rbac-bootstrap-owner": "node --experimental-transform-types --test scripts/bootstrap-workspace-owner.test.ts",
"test:coverage": "npm run test:coverage --workspaces --if-present",
"test:integration": "npm run test:integration --workspaces --if-present",
"typecheck": "npm run build -w @open-inspect/shared && npm run typecheck --workspaces --if-present",
"knip": "knip",
"build": "npm run build -w @open-inspect/shared && npm run build --workspaces --if-present",
"build:opencomputer-template": "npm run build-template -w @open-inspect/opencomputer-infra --",
"rbac:bootstrap-owner": "node --experimental-transform-types scripts/bootstrap-workspace-owner.ts",
"prepare": "node -e \"if (process.env.CI) process.exit(0)\" && husky"
},
"devDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions packages/control-plane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ The control plane provides:

| Endpoint | Method | Description |
| ------------------------------- | --------- | ------------------------------ |
| `/sessions` | GET | List user's sessions |
| `/sessions` | GET | List workspace sessions |
| `/sessions` | POST | Create new session |
| `/sessions/:id` | GET | Get canonical session snapshot |
| `/sessions/:id` | DELETE | Delete session |
Expand All @@ -67,7 +67,7 @@ The control plane provides:
| `/sessions/:id/ws` | WebSocket | Real-time connection |
| `/sessions/:id/events` | GET | Paginated events |
| `/sessions/:id/artifacts` | GET | List artifacts |
| `/sessions/:id/participants` | GET/POST | Manage participants |
| `/sessions/:id/participants` | GET | List runtime participants |
| `/sessions/:id/messages` | GET | List messages |
| `/sessions/:id/pr` | POST | Create pull request |
| `/sessions/:id/scm-credentials` | POST | Broker sandbox git credentials |
Expand Down
58 changes: 33 additions & 25 deletions packages/control-plane/src/auth/identity-enforcement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,17 @@ const SLACK_BOT_PRINCIPAL: Principal = {
};

function createCtx(principal?: Principal): RequestContext {
const statement = {
bind: vi.fn(() => statement),
first: vi.fn(async () => ({ active: 1 })),
};
return {
trace_id: "trace-test",
request_id: "req-test",
principal,
db: { prepare: vi.fn(() => statement) },
executionCtx: TEST_BACKGROUND_TASK_CONTEXT,
} as RequestContext;
} as unknown as RequestContext;
}

function loggedEvents(spy: { mock: { calls: unknown[][] } }): Array<Record<string, unknown>> {
Expand Down Expand Up @@ -93,26 +98,7 @@ describe("applyIdentityEnforcement — identityless principals", () => {
});

describe("applyIdentityEnforcement — forbidden-field rejection", () => {
it("rejects forbidden keys with a 400 naming the field", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const { rejection } = applyIdentityEnforcement(createCtx(USER_PRINCIPAL), "session-lifecycle", {
userId: "someone",
title: "ok",
});
expect(rejection).toBeDefined();
expect(rejection!.status).toBe(400);
expect(((await rejection!.clone().json()) as { error: string }).error).toBe(
"Field 'userId' is not accepted from verified callers"
);
const logged = loggedEvents(warn).find((e) => e.event === "identity.forbidden_field_rejected");
expect(logged).toMatchObject({ route: "session-lifecycle", field: "userId" });
});

it("accepts bodies carrying only permitted fields", () => {
expect(
applyIdentityEnforcement(createCtx(USER_PRINCIPAL), "session-lifecycle", { title: "ok" })
.rejection
).toBeUndefined();
expect(
applyIdentityEnforcement(createCtx(USER_PRINCIPAL), "session-create", {
scmLogin: "ada",
Expand Down Expand Up @@ -194,11 +180,9 @@ describe("applyIdentityEnforcement — requires-user rejection", () => {
});

it("does not gate routes that accept participantless principals", () => {
for (const route of ["prompt", "session-lifecycle"] as const) {
const result = applyIdentityEnforcement(createCtx(ACTORLESS_BOT), route, {});
expect(result.rejection).toBeUndefined();
expect(result.enforced).toMatchObject({ participantUserId: null });
}
const result = applyIdentityEnforcement(createCtx(ACTORLESS_BOT), "prompt", {});
expect(result.rejection).toBeUndefined();
expect(result.enforced).toMatchObject({ participantUserId: null });
});
});

Expand Down Expand Up @@ -241,6 +225,30 @@ describe("resolveCanonicalUserId", () => {
);
});

it("rejects a canonical identity whose workspace access is suspended", async () => {
const ctx = createCtx(USER_PRINCIPAL);
const statement = {
bind: vi.fn(() => statement),
first: vi.fn(async () => null),
};
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;
Expand Down
32 changes: 21 additions & 11 deletions packages/control-plane/src/auth/identity-enforcement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,7 @@ import { error, type RequestContext } from "../routes/shared";
const logger = createLogger("identity-enforcement");

/** The route families that consume caller-supplied identity. */
export type IdentityRoute =
| "session-create"
| "ws-token"
| "prompt"
| "session-lifecycle"
| "automation-create";
type IdentityRoute = "session-create" | "ws-token" | "prompt" | "automation-create";

const SPAWNING_FORBIDDEN_FIELDS = [
"userId",
Expand All @@ -50,7 +45,6 @@ const FORBIDDEN_IDENTITY_FIELDS: Record<IdentityRoute, readonly string[]> = {
"session-create": SPAWNING_FORBIDDEN_FIELDS,
"ws-token": ["userId", "scmToken", "scmRefreshToken", "scmUserId"],
prompt: ["authorId"],
"session-lifecycle": ["userId"],
"automation-create": SPAWNING_FORBIDDEN_FIELDS,
};

Expand All @@ -74,7 +68,7 @@ function requiresUserMessage(route: IdentityRoute): string | undefined {
}

/** Identity a verified principal implies for a consuming route. */
export interface DerivedIdentity {
interface DerivedIdentity {
/** DO participant id: bare canonical id for users, `ns:id` for bot actors. */
participantUserId: string | null;
/** Canonical D1 users.id when the principal resolves to one. */
Expand Down Expand Up @@ -135,7 +129,7 @@ function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

export type IdentityEnforcement<R extends IdentityRoute> =
type IdentityEnforcement<R extends IdentityRoute> =
| { rejection: Response; enforced?: undefined }
| { rejection?: undefined; enforced: EnforcedIdentity<R> };

Expand Down Expand Up @@ -198,7 +192,23 @@ export async function resolveCanonicalUserId(
enforced: DerivedIdentity & { participantUserId: string },
display: { displayName?: string; email?: string; avatarUrl?: string }
): Promise<{ userId: string } | Response> {
if (enforced.canonicalUserId) return { userId: enforced.canonicalUserId };
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
Expand All @@ -219,7 +229,7 @@ export async function resolveCanonicalUserId(
providerEmail: display.email,
avatarUrl: display.avatarUrl,
});
return { userId: user.id };
return requireActive(user.id);
} catch (e) {
logger.error("Failed to resolve verified actor identity", {
error: e instanceof Error ? e : String(e),
Expand Down
11 changes: 11 additions & 0 deletions packages/control-plane/src/authorization/permission-sql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { rolePermissionPredicate } from "./permission-sql";

describe("rolePermissionPredicate", () => {
it("never grants ownership transfer through a custom role", () => {
const predicate = rolePermissionPredicate("workspace.transfer_ownership");

expect(predicate.sql).not.toContain("role_permissions");
expect(predicate.values).toEqual(["owner"]);
});
});
29 changes: 29 additions & 0 deletions packages/control-plane/src/authorization/permission-sql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
BUILT_IN_ROLE_KEYS,
isCustomRolePermission,
permissionsForBuiltInRole,
type PermissionId,
} from "@open-inspect/shared/rbac";

/** Builds a parameterized role predicate that enforces built-in and custom-role grant rules. */
export function rolePermissionPredicate(permission: PermissionId): {
sql: string;
values: string[];
} {
const builtInRoles = BUILT_IN_ROLE_KEYS.filter((role) =>
permissionsForBuiltInRole(role).includes(permission)
);
const customRolePermission = isCustomRolePermission(permission);
const customRoleSql = customRolePermission
? `r.key IS NULL AND EXISTS (
SELECT 1 FROM role_permissions custom_permission
WHERE custom_permission.role_id = r.id
AND custom_permission.permission_id = ?
)`
: "0";
return {
sql: `(r.key IN (${builtInRoles.map(() => "?").join(", ")})
OR (${customRoleSql}))`,
values: [...builtInRoles, ...(customRolePermission ? [permission] : [])],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { serviceAllowsPermission } from "./service-permissions";

describe("serviceAllowsPermission", () => {
it("allows launch capabilities but denies management capabilities", () => {
expect(serviceAllowsPermission("slack-bot", "sessions.create")).toBe(true);
expect(serviceAllowsPermission("slack-bot", "global_secrets.manage")).toBe(false);
expect(serviceAllowsPermission("github-bot", "sessions.sandbox_access")).toBe(false);
});
});
45 changes: 45 additions & 0 deletions packages/control-plane/src/authorization/service-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { PermissionId } from "@open-inspect/shared/rbac";
import type { ServiceName } from "@open-inspect/shared/service-auth";

const SERVICE_PERMISSION_CEILINGS: Record<ServiceName, readonly PermissionId[]> = {
web: [],
"github-bot": [
"repositories.read",
"repositories.use",
"environments.read",
"environments.use",
"sessions.create",
"sessions.read",
"sessions.collaborate",
"sessions.lifecycle",
"skills.read",
],
"slack-bot": [
"repositories.read",
"repositories.use",
"environments.read",
"environments.use",
"sessions.create",
"sessions.read",
"sessions.collaborate",
"sessions.lifecycle",
"sessions.sandbox_access",
"skills.read",
],
"linear-bot": [
"repositories.read",
"repositories.use",
"environments.read",
"environments.use",
"sessions.create",
"sessions.read",
"sessions.collaborate",
"sessions.lifecycle",
"skills.read",
],
};

/** Checks the hard permission ceiling for a trusted service, independent of user grants. */
export function serviceAllowsPermission(service: ServiceName, permission: PermissionId): boolean {
return SERVICE_PERMISSION_CEILINGS[service].includes(permission);
}
Loading