Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e0a0c70
feat: add RBAC contracts and persistence
ColeMurray Aug 31, 2026
46b620d
feat: enforce workspace permissions at the HTTP boundary
ColeMurray Aug 31, 2026
68789f0
feat: enforce session authorization and revoke stale sockets
ColeMurray Aug 31, 2026
9c9403d
feat: enforce automation ownership and execution authority
ColeMurray Aug 31, 2026
4866d41
fix(rbac): address foundation review feedback
ColeMurray Aug 31, 2026
ab4dc1b
Merge branch 'rbac-foundation' into rbac-http-enforcement
ColeMurray Aug 31, 2026
34e6d03
Merge branch 'rbac-http-enforcement' into rbac-session-authorization
ColeMurray Aug 31, 2026
ba3774c
Merge branch 'rbac-session-authorization' into rbac-automation-author…
ColeMurray Aug 31, 2026
675a554
fix(rbac): preserve merge batch result contract
ColeMurray Aug 31, 2026
cf5e5e5
Merge branch 'rbac-foundation' into rbac-http-enforcement
ColeMurray Aug 31, 2026
0c33bb8
Merge branch 'rbac-http-enforcement' into rbac-session-authorization
ColeMurray Aug 31, 2026
2644c82
Merge branch 'rbac-session-authorization' into rbac-automation-author…
ColeMurray Aug 31, 2026
f799ffa
Merge main into rbac-session-authorization
ColeMurray Aug 31, 2026
08afe2b
fix: harden websocket authorization lifecycle
ColeMurray Aug 31, 2026
369fbbd
Merge branch 'rbac-session-authorization' into rbac-automation-author…
ColeMurray Aug 31, 2026
7792a9c
fix: address automation authorization review feedback
ColeMurray Aug 31, 2026
5ad2071
Merge remote-tracking branch 'origin/main' into HEAD
ColeMurray Aug 31, 2026
d55b160
test: add automation owner ids to web fixtures
ColeMurray Aug 31, 2026
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