From e0a0c70559dc6b18b6bdf6c3fc4f849fa48059d2 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Sun, 30 Aug 2026 21:38:28 -0700
Subject: [PATCH 1/9] feat: add RBAC contracts and persistence
---
.github/workflows/ci.yml | 3 +
package.json | 2 +
.../src/authorization/permission-sql.test.ts | 11 +
.../src/authorization/permission-sql.ts | 29 ++
.../src/authorization/service.ts | 166 +++++++
.../src/db/authorization-store.test.ts | 86 ++++
.../src/db/authorization-store.ts | 445 ++++++++++++++++++
packages/control-plane/src/db/user-merge.ts | 388 +++++++++------
.../integration/browser-auth-callback.test.ts | 13 +
.../test/integration/browser-auth.test.ts | 1 +
.../control-plane/test/integration/cleanup.ts | 2 +-
.../migration-0071-rbac-foundation.test.ts | 134 ++++++
.../test/integration/rbac-foundation.test.ts | 27 ++
.../integration/session-read-state.test.ts | 3 +
.../test/integration/user-merge.test.ts | 128 ++++-
.../test/integration/user-store.test.ts | 22 +
packages/shared/package.json | 4 +
packages/shared/src/index.ts | 1 +
packages/shared/src/rbac.test.ts | 133 ++++++
packages/shared/src/rbac.ts | 219 +++++++++
scripts/bootstrap-workspace-owner.test.ts | 310 ++++++++++++
scripts/bootstrap-workspace-owner.ts | 284 +++++++++++
.../d1/migrations/0071_rbac_foundation.sql | 71 +++
terraform/environments/production/outputs.tf | 5 +
24 files changed, 2347 insertions(+), 140 deletions(-)
create mode 100644 packages/control-plane/src/authorization/permission-sql.test.ts
create mode 100644 packages/control-plane/src/authorization/permission-sql.ts
create mode 100644 packages/control-plane/src/authorization/service.ts
create mode 100644 packages/control-plane/src/db/authorization-store.test.ts
create mode 100644 packages/control-plane/src/db/authorization-store.ts
create mode 100644 packages/control-plane/test/integration/migration-0071-rbac-foundation.test.ts
create mode 100644 packages/control-plane/test/integration/rbac-foundation.test.ts
create mode 100644 packages/shared/src/rbac.test.ts
create mode 100644 packages/shared/src/rbac.ts
create mode 100644 scripts/bootstrap-workspace-owner.test.ts
create mode 100644 scripts/bootstrap-workspace-owner.ts
create mode 100644 terraform/d1/migrations/0071_rbac_foundation.sql
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d9f75ac73..712ab6035 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/package.json b/package.json
index aef1af3ae..35871e3ed 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/packages/control-plane/src/authorization/permission-sql.test.ts b/packages/control-plane/src/authorization/permission-sql.test.ts
new file mode 100644
index 000000000..662aea942
--- /dev/null
+++ b/packages/control-plane/src/authorization/permission-sql.test.ts
@@ -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"]);
+ });
+});
diff --git a/packages/control-plane/src/authorization/permission-sql.ts b/packages/control-plane/src/authorization/permission-sql.ts
new file mode 100644
index 000000000..31213fed0
--- /dev/null
+++ b/packages/control-plane/src/authorization/permission-sql.ts
@@ -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] : [])],
+ };
+}
diff --git a/packages/control-plane/src/authorization/service.ts b/packages/control-plane/src/authorization/service.ts
new file mode 100644
index 000000000..36da1c984
--- /dev/null
+++ b/packages/control-plane/src/authorization/service.ts
@@ -0,0 +1,166 @@
+import {
+ isRegisteredPermission,
+ isCustomRolePermission,
+ permissionsForBuiltInRole,
+ type BuiltInRoleKey,
+ type EffectiveAuthorization,
+ type PermissionId,
+ type RoleSummary,
+ type WorkspaceMember,
+} from "@open-inspect/shared/rbac";
+import {
+ AuthorizationStore,
+ type AuthorizationMutationOutcome,
+ type AuthorizationRoleRecord,
+} from "../db/authorization-store";
+import type { SqlDatabase } from "../db/sql-database";
+
+/** Represents an authorization denial that can be translated directly to an API response. */
+export class AuthorizationError extends Error {
+ /** Creates a denial with its HTTP status, stable error code, and optional missing grant. */
+ constructor(
+ readonly status: number,
+ readonly code: string,
+ readonly permission?: PermissionId
+ ) {
+ super(code);
+ this.name = "AuthorizationError";
+ }
+}
+
+/** Signals that RBAC state changed or violated an invariant during a guarded mutation. */
+export class RbacConflictError extends Error {
+ /** Creates a conflict suitable for retry or refreshed administrative state. */
+ constructor(message: string) {
+ super(message);
+ this.name = "RbacConflictError";
+ }
+}
+
+/** Resolves effective grants and coordinates invariant-preserving workspace RBAC mutations. */
+export class AuthorizationService {
+ private readonly store: AuthorizationStore;
+
+ /** Creates a service backed by the workspace authorization database. */
+ constructor(db: SqlDatabase) {
+ this.store = new AuthorizationStore(db);
+ }
+
+ /** Resolves a user's assigned role and grants, withholding all grants while suspended. */
+ async getEffectiveAuthorization(userId: string): Promise {
+ const record = await this.store.getEffectiveAuthorization(userId);
+ if (!record?.role) throw new AuthorizationError(403, "assignment_required");
+
+ const permissions =
+ record.suspendedAt === null
+ ? await this.loadRolePermissions(record.role.id, record.role.key)
+ : [];
+
+ return {
+ userId: record.userId,
+ suspendedAt: record.suspendedAt,
+ role: record.role,
+ permissions,
+ };
+ }
+
+ /** Returns active authorization when the grant is present, or throws a structured denial. */
+ async requirePermission(
+ userId: string,
+ permission: PermissionId
+ ): Promise {
+ const authorization = await this.getEffectiveAuthorization(userId);
+ if (authorization.suspendedAt !== null) {
+ throw new AuthorizationError(403, "active_user_required");
+ }
+ if (!authorization.permissions.includes(permission)) {
+ throw new AuthorizationError(403, "permission_required", permission);
+ }
+ return authorization;
+ }
+
+ /** Lists roles with their effective permissions and current assignment counts. */
+ async listRoles(): Promise {
+ const roles = await this.store.listRoles();
+ return Promise.all(roles.map((role) => this.toRoleSummary(role)));
+ }
+
+ /** Returns a role's effective authorization summary, or null when it does not exist. */
+ async getRole(roleId: string): Promise {
+ const role = await this.store.getRole(roleId);
+ return role ? this.toRoleSummary(role) : null;
+ }
+
+ /** Lists assigned workspace members with suspension and role state. */
+ async listMembers(): Promise {
+ return this.store.listMembers();
+ }
+
+ /** Replaces a member's role under actor revalidation and ownership invariants. */
+ async replaceMemberRole(input: {
+ targetUserId: string;
+ roleId: string;
+ actorUserId: string;
+ requestId: string;
+ }): Promise {
+ this.requireApplied(
+ await this.store.replaceMemberRole({
+ targetUserId: input.targetUserId,
+ roleId: input.roleId,
+ actorUserId: input.actorUserId,
+ requestId: input.requestId,
+ now: Date.now(),
+ }),
+ "Member role precondition conflict"
+ );
+ }
+
+ /** Suspends or reactivates a member while preserving an active workspace owner. */
+ async replaceMemberStatus(input: {
+ targetUserId: string;
+ suspended: boolean;
+ actorUserId: string;
+ requestId: string;
+ }): Promise {
+ this.requireApplied(
+ await this.store.replaceMemberStatus({
+ targetUserId: input.targetUserId,
+ suspended: input.suspended,
+ actorUserId: input.actorUserId,
+ requestId: input.requestId,
+ now: Date.now(),
+ }),
+ "Member status precondition conflict"
+ );
+ }
+
+ private async loadRolePermissions(
+ roleId: string,
+ roleKey: BuiltInRoleKey | null
+ ): Promise {
+ if (roleKey) return permissionsForBuiltInRole(roleKey);
+ return (await this.store.getCustomRolePermissions(roleId)).filter(
+ (permission): permission is PermissionId =>
+ isRegisteredPermission(permission) && isCustomRolePermission(permission)
+ );
+ }
+
+ private async toRoleSummary(role: AuthorizationRoleRecord): Promise {
+ return {
+ ...role,
+ permissions: await this.loadRolePermissions(role.id, role.key),
+ };
+ }
+
+ private requireApplied(outcome: AuthorizationMutationOutcome, conflictMessage: string): void {
+ if (outcome.status === "actor_authorization_changed") {
+ throw new RbacConflictError("Actor authorization changed");
+ }
+ if (outcome.status === "not_found") {
+ throw new AuthorizationError(404, "role_not_found");
+ }
+ if (outcome.status === "conflict") {
+ throw new RbacConflictError(conflictMessage);
+ }
+ }
+}
diff --git a/packages/control-plane/src/db/authorization-store.test.ts b/packages/control-plane/src/db/authorization-store.test.ts
new file mode 100644
index 000000000..780045503
--- /dev/null
+++ b/packages/control-plane/src/db/authorization-store.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vitest";
+import { AuthorizationStore } from "./authorization-store";
+import type { SqlDatabase, SqlResult, SqlStatement } from "./sql-database";
+
+function result(changes: number, rows: unknown[] = []): SqlResult {
+ return { results: rows, meta: { changes } };
+}
+
+function fakeDatabase(options: {
+ batchResults?: SqlResult[];
+ batchError?: Error;
+ allResults?: unknown[];
+}): SqlDatabase {
+ const statement: SqlStatement = {
+ bind: () => statement,
+ first: async () => null as T | null,
+ run: async () => result(0) as SqlResult,
+ all: async () => result(0, options.allResults) as SqlResult,
+ };
+ return {
+ prepare: () => statement,
+ batch: async () => {
+ if (options.batchError) throw options.batchError;
+ return (options.batchResults ?? []) as SqlResult[];
+ },
+ };
+}
+
+const replaceMemberStatusInput: Parameters[0] = {
+ targetUserId: "target",
+ suspended: true,
+ actorUserId: "actor",
+ requestId: "request",
+ now: 100,
+};
+
+describe("AuthorizationStore", () => {
+ it("maps persistence role fields at the store boundary", async () => {
+ const store = new AuthorizationStore(
+ fakeDatabase({
+ allResults: [
+ {
+ id: "role_custom",
+ key: null,
+ name: "Custom",
+ description: null,
+ is_system: 0,
+ assignment_count: "4",
+ },
+ ],
+ })
+ );
+
+ await expect(store.listRoles()).resolves.toEqual([
+ {
+ id: "role_custom",
+ key: null,
+ name: "Custom",
+ description: null,
+ assignmentCount: 4,
+ },
+ ]);
+ });
+
+ it.each(["applied", "actor_authorization_changed", "not_found", "conflict"] as const)(
+ "returns the %s member status replacement batch outcome",
+ async (status) => {
+ const store = new AuthorizationStore(
+ fakeDatabase({
+ batchResults: [result(0, [{ status }]), result(1), result(1), result(1)],
+ })
+ );
+
+ await expect(store.replaceMemberStatus(replaceMemberStatusInput)).resolves.toEqual({
+ status,
+ });
+ }
+ );
+
+ it("does not classify an unexpected database failure as a conflict", async () => {
+ const failure = new Error("database unavailable");
+ const store = new AuthorizationStore(fakeDatabase({ batchError: failure }));
+
+ await expect(store.replaceMemberStatus(replaceMemberStatusInput)).rejects.toBe(failure);
+ });
+});
diff --git a/packages/control-plane/src/db/authorization-store.ts b/packages/control-plane/src/db/authorization-store.ts
new file mode 100644
index 000000000..f03b54605
--- /dev/null
+++ b/packages/control-plane/src/db/authorization-store.ts
@@ -0,0 +1,445 @@
+import {
+ BUILT_IN_ROLE_REGISTRY,
+ type BuiltInRoleKey,
+ type PermissionId,
+ type WorkspaceMember,
+} from "@open-inspect/shared/rbac";
+import { rolePermissionPredicate } from "../authorization/permission-sql";
+import type { SqlDatabase, SqlStatement } from "./sql-database";
+
+const OWNER_ROLE_ID = BUILT_IN_ROLE_REGISTRY.owner.id;
+
+interface EffectiveRow {
+ user_id: string;
+ suspended_at: number | null;
+ role_id: string | null;
+ role_key: BuiltInRoleKey | null;
+ role_name: string | null;
+}
+
+interface RoleRow {
+ id: string;
+ key: BuiltInRoleKey | null;
+ name: string;
+ description: string | null;
+ assignment_count: number;
+}
+
+interface MemberRow {
+ user_id: string;
+ display_name: string | null;
+ email: string | null;
+ suspended_at: number | null;
+ role_id: string;
+ role_key: BuiltInRoleKey | null;
+ role_name: string;
+}
+
+/** Persistence view of a user's assignment and suspension state before grants are resolved. */
+export interface EffectiveAuthorizationRecord {
+ userId: string;
+ suspendedAt: number | null;
+ role: { id: string; key: BuiltInRoleKey | null; name: string } | null;
+}
+
+/** Persistence view of a role and the number of users currently assigned to it. */
+export interface AuthorizationRoleRecord {
+ id: string;
+ key: BuiltInRoleKey | null;
+ name: string;
+ description: string | null;
+ assignmentCount: number;
+}
+
+interface AuditInput {
+ requestId: string;
+ actorUserId: string;
+ action: string;
+ resourceType: string;
+ resourceId?: string | null;
+ targetUserId?: string | null;
+ reasonCode: string;
+ occurredAt: number;
+}
+
+interface SqlCondition {
+ sql: string;
+ values: unknown[];
+}
+
+function userIsOwner(userId: string): SqlCondition {
+ return {
+ sql: `EXISTS (
+ SELECT 1 FROM user_role_assignments assignment
+ WHERE assignment.user_id = ? AND assignment.role_id = ?
+ )`,
+ values: [userId, OWNER_ROLE_ID],
+ };
+}
+
+function anotherUnsuspendedOwner(targetUserId: string): SqlCondition {
+ return {
+ sql: `EXISTS (
+ SELECT 1 FROM users other_user
+ JOIN user_role_assignments other_assignment ON other_assignment.user_id = other_user.id
+ WHERE other_assignment.role_id = ? AND other_user.suspended_at IS NULL
+ AND other_user.id <> ?
+ )`,
+ values: [OWNER_ROLE_ID, targetUserId],
+ };
+}
+
+/** Result of an atomic RBAC mutation after authorization and invariant checks. */
+export type AuthorizationMutationOutcome =
+ | { status: "applied" }
+ | { status: "actor_authorization_changed" }
+ | { status: "not_found" }
+ | { status: "conflict" };
+
+function toEffectiveAuthorizationRecord(row: EffectiveRow): EffectiveAuthorizationRecord {
+ return {
+ userId: row.user_id,
+ suspendedAt: row.suspended_at,
+ role:
+ row.role_id && row.role_name
+ ? { id: row.role_id, key: row.role_key, name: row.role_name }
+ : null,
+ };
+}
+
+function toRoleRecord(row: RoleRow): AuthorizationRoleRecord {
+ return {
+ id: row.id,
+ key: row.key,
+ name: row.name,
+ description: row.description,
+ assignmentCount: Number(row.assignment_count),
+ };
+}
+
+function toMember(row: MemberRow): WorkspaceMember {
+ return {
+ userId: row.user_id,
+ displayName: row.display_name,
+ email: row.email,
+ suspendedAt: row.suspended_at,
+ role: { id: row.role_id, key: row.role_key, name: row.role_name },
+ };
+}
+
+/** Persists RBAC reads and authorization-guarded, audited member mutations. */
+export class AuthorizationStore {
+ /** Creates a store using the workspace's SQL database. */
+ constructor(private readonly db: SqlDatabase) {}
+
+ /** Loads assignment and suspension state without resolving the role's permissions. */
+ async getEffectiveAuthorization(userId: string): Promise {
+ const row = await this.db
+ .prepare(
+ `SELECT u.id AS user_id, u.suspended_at,
+ r.id AS role_id, r.key AS role_key, r.name AS role_name
+ FROM users u
+ LEFT JOIN user_role_assignments ura ON ura.user_id = u.id
+ LEFT JOIN roles r ON r.id = ura.role_id
+ WHERE u.id = ?`
+ )
+ .bind(userId)
+ .first();
+ return row ? toEffectiveAuthorizationRecord(row) : null;
+ }
+
+ /** Loads raw custom-role grants for policy-layer validation against the registry. */
+ async getCustomRolePermissions(roleId: string): Promise {
+ const result = await this.db
+ .prepare(
+ "SELECT permission_id FROM role_permissions WHERE role_id = ? ORDER BY permission_id"
+ )
+ .bind(roleId)
+ .all<{ permission_id: string }>();
+ return result.results.map((row) => row.permission_id);
+ }
+
+ /** Lists built-in and custom roles with current assignment counts. */
+ async listRoles(): Promise {
+ const result = await this.db
+ .prepare(
+ `SELECT r.id, r.key, r.name, r.description,
+ COUNT(ura.user_id) AS assignment_count
+ FROM roles r
+ LEFT JOIN user_role_assignments ura ON ura.role_id = r.id
+ GROUP BY r.id
+ ORDER BY r.is_system DESC, r.normalized_name ASC`
+ )
+ .all();
+ return result.results.map(toRoleRecord);
+ }
+
+ /** Loads a role and its assignment count, or null when absent. */
+ async getRole(roleId: string): Promise {
+ const row = await this.db
+ .prepare(
+ `SELECT r.id, r.key, r.name, r.description,
+ COUNT(ura.user_id) AS assignment_count
+ FROM roles r
+ LEFT JOIN user_role_assignments ura ON ura.role_id = r.id
+ WHERE r.id = ? GROUP BY r.id`
+ )
+ .bind(roleId)
+ .first();
+ return row ? toRoleRecord(row) : null;
+ }
+
+ /** Lists users with role assignments; unassigned users are intentionally excluded. */
+ async listMembers(): Promise {
+ const result = await this.db
+ .prepare(
+ `SELECT u.id AS user_id, u.display_name, u.email, u.suspended_at,
+ r.id AS role_id, r.key AS role_key, r.name AS role_name
+ FROM users u
+ JOIN user_role_assignments ura ON ura.user_id = u.id
+ JOIN roles r ON r.id = ura.role_id
+ ORDER BY COALESCE(u.display_name, u.email, u.id) COLLATE NOCASE`
+ )
+ .all();
+ return result.results.map(toMember);
+ }
+
+ /** Atomically revalidates the actor, preserves owner invariants, updates the role, and audits. */
+ async replaceMemberRole(input: {
+ targetUserId: string;
+ roleId: string;
+ actorUserId: string;
+ requestId: string;
+ now: number;
+ }): Promise {
+ const transferGuard = rolePermissionPredicate("workspace.transfer_ownership");
+ const targetIsOwner = userIsOwner(input.targetUserId);
+ const otherOwnerExists = anotherUnsuspendedOwner(input.targetUserId);
+ const mutation = this.mutationConditions(
+ input.actorUserId,
+ ["workspace.members.manage"],
+ {
+ sql: `EXISTS (SELECT 1 FROM roles WHERE id = ?)
+ AND EXISTS (SELECT 1 FROM user_role_assignments WHERE user_id = ?)
+ AND (
+ ? = ?
+ OR NOT (${targetIsOwner.sql})
+ OR (${otherOwnerExists.sql})
+ )`,
+ values: [
+ input.roleId,
+ input.targetUserId,
+ input.roleId,
+ OWNER_ROLE_ID,
+ ...targetIsOwner.values,
+ ...otherOwnerExists.values,
+ ],
+ },
+ {
+ actor: {
+ sql: `(? <> ? AND NOT (${targetIsOwner.sql})) OR ${transferGuard.sql}`,
+ values: [input.roleId, OWNER_ROLE_ID, ...targetIsOwner.values, ...transferGuard.values],
+ },
+ }
+ );
+ const results = await this.db.batch([
+ mutation.outcome,
+ this.auditStatement(
+ {
+ requestId: input.requestId,
+ actorUserId: input.actorUserId,
+ action: "workspace.member_role_updated",
+ resourceType: "user",
+ resourceId: input.targetUserId,
+ targetUserId: input.targetUserId,
+ reasonCode: "member_role_updated",
+ occurredAt: input.now,
+ },
+ mutation.applied,
+ mutation.auditId
+ ),
+ this.db
+ .prepare(`UPDATE users SET updated_at = ? WHERE id = ? AND ${mutation.writes.sql}`)
+ .bind(input.now, input.targetUserId, ...mutation.writes.values),
+ this.db
+ .prepare(
+ `UPDATE user_role_assignments SET role_id = ?
+ WHERE user_id = ? AND ${mutation.writes.sql}`
+ )
+ .bind(input.roleId, input.targetUserId, ...mutation.writes.values),
+ ]);
+ return this.readMutationOutcome(results[0]);
+ }
+
+ /** Atomically revalidates the actor, preserves owner invariants, changes status, and audits. */
+ async replaceMemberStatus(input: {
+ targetUserId: string;
+ suspended: boolean;
+ actorUserId: string;
+ requestId: string;
+ now: number;
+ }): Promise {
+ const transferGuard = rolePermissionPredicate("workspace.transfer_ownership");
+ const targetIsOwner = userIsOwner(input.targetUserId);
+ const otherOwnerExists = anotherUnsuspendedOwner(input.targetUserId);
+ const mutation = this.mutationConditions(
+ input.actorUserId,
+ ["workspace.members.manage"],
+ {
+ sql: `EXISTS (
+ SELECT 1 FROM users
+ JOIN user_role_assignments ON user_role_assignments.user_id = users.id
+ WHERE users.id = ?
+ )
+ AND (
+ ? = 0
+ OR NOT (${targetIsOwner.sql})
+ OR (${otherOwnerExists.sql})
+ )`,
+ values: [
+ input.targetUserId,
+ input.suspended ? 1 : 0,
+ ...targetIsOwner.values,
+ ...otherOwnerExists.values,
+ ],
+ },
+ {
+ actor: {
+ sql: `NOT (${targetIsOwner.sql}) OR ${transferGuard.sql}`,
+ values: [...targetIsOwner.values, ...transferGuard.values],
+ },
+ }
+ );
+ const statements: SqlStatement[] = [
+ mutation.outcome,
+ this.auditStatement(
+ {
+ requestId: input.requestId,
+ actorUserId: input.actorUserId,
+ action: "workspace.member_status_updated",
+ resourceType: "user",
+ resourceId: input.targetUserId,
+ targetUserId: input.targetUserId,
+ reasonCode: "member_status_updated",
+ occurredAt: input.now,
+ },
+ mutation.applied,
+ mutation.auditId
+ ),
+ ];
+ if (input.suspended) {
+ statements.push(
+ this.db
+ .prepare(`DELETE FROM auth_sessions WHERE userId = ? AND ${mutation.writes.sql}`)
+ .bind(input.targetUserId, ...mutation.writes.values)
+ );
+ }
+ statements.push(
+ this.db
+ .prepare(
+ `UPDATE users SET suspended_at = ?, updated_at = ?
+ WHERE id = ? AND ${mutation.writes.sql}`
+ )
+ .bind(
+ input.suspended ? input.now : null,
+ input.now,
+ input.targetUserId,
+ ...mutation.writes.values
+ )
+ );
+ const results = await this.db.batch(statements);
+ return this.readMutationOutcome(results[0]);
+ }
+
+ private mutationConditions(
+ actorUserId: string,
+ permissions: PermissionId[],
+ resourceCondition: SqlCondition,
+ options?: { actor?: SqlCondition; notFound?: SqlCondition }
+ ): {
+ outcome: SqlStatement;
+ applied: SqlCondition;
+ writes: SqlCondition;
+ auditId: string;
+ } {
+ const permissionGuards = permissions.map(rolePermissionPredicate);
+ const actor: SqlCondition = {
+ sql: `EXISTS (
+ SELECT 1 FROM users u
+ JOIN user_role_assignments ura ON ura.user_id = u.id
+ JOIN roles r ON r.id = ura.role_id
+ WHERE u.id = ? AND u.suspended_at IS NULL
+ AND ${permissionGuards.map((guard) => guard.sql).join(" AND ")}
+ ${options?.actor ? `AND (${options.actor.sql})` : ""}
+ )`,
+ values: [
+ actorUserId,
+ ...permissionGuards.flatMap((guard) => guard.values),
+ ...(options?.actor?.values ?? []),
+ ],
+ };
+ const applied: SqlCondition = {
+ sql: `(${actor.sql}) AND (${resourceCondition.sql})`,
+ values: [...actor.values, ...resourceCondition.values],
+ };
+ const auditId = crypto.randomUUID();
+ return {
+ outcome: this.db
+ .prepare(
+ `SELECT CASE
+ WHEN NOT (${actor.sql}) THEN 'actor_authorization_changed'
+ ${options?.notFound ? `WHEN (${options.notFound.sql}) THEN 'not_found'` : ""}
+ WHEN NOT (${resourceCondition.sql}) THEN 'conflict'
+ ELSE 'applied'
+ END AS status`
+ )
+ .bind(...actor.values, ...(options?.notFound?.values ?? []), ...resourceCondition.values),
+ applied,
+ writes: {
+ sql: "EXISTS (SELECT 1 FROM authorization_audit_events WHERE id = ?)",
+ values: [auditId],
+ },
+ auditId,
+ };
+ }
+
+ private readMutationOutcome(result: { results: unknown[] }): AuthorizationMutationOutcome {
+ const status = (result.results[0] as { status?: unknown } | undefined)?.status;
+ if (
+ status !== "applied" &&
+ status !== "actor_authorization_changed" &&
+ status !== "not_found" &&
+ status !== "conflict"
+ ) {
+ throw new Error("Invalid authorization mutation outcome");
+ }
+ return { status };
+ }
+
+ private auditStatement(
+ input: AuditInput,
+ condition: SqlCondition,
+ auditId: string
+ ): SqlStatement {
+ return this.db
+ .prepare(
+ `INSERT INTO authorization_audit_events
+ (id, occurred_at, request_id, principal_kind,
+ actor_user_id_snapshot, action, resource_type, resource_id,
+ target_user_id_snapshot, reason_code)
+ SELECT ?, ?, ?, 'user', ?, ?, ?, ?, ?, ? WHERE ${condition.sql}`
+ )
+ .bind(
+ auditId,
+ input.occurredAt,
+ input.requestId,
+ input.actorUserId,
+ input.action,
+ input.resourceType,
+ input.resourceId ?? null,
+ input.targetUserId ?? null,
+ input.reasonCode,
+ ...condition.values
+ );
+ }
+}
diff --git a/packages/control-plane/src/db/user-merge.ts b/packages/control-plane/src/db/user-merge.ts
index d14079eb4..e098a2815 100644
--- a/packages/control-plane/src/db/user-merge.ts
+++ b/packages/control-plane/src/db/user-merge.ts
@@ -1,4 +1,4 @@
-import type { SqlDatabase, SqlStatement } from "./sql-database";
+import type { SqlDatabase, SqlResult, SqlStatement } from "./sql-database";
/**
* Split-merge primitive: converge a loser canonical user's entire graph onto
@@ -26,8 +26,9 @@ import type { SqlDatabase, SqlStatement } from "./sql-database";
* the preceding statement, so a stop exactly between those two statements
* is not re-derivable from the database. The CLI prints a recovery record
* before executing to cover that residual case.
- * - Browser sessions (`auth_sessions`) are re-pointed, not deleted — the
- * merged person stays signed in as the survivor.
+ * - Browser sessions (`auth_sessions`) issued to the loser are deleted. An
+ * issued bearer credential is never rewritten to authenticate as another
+ * canonical user.
* - Verification never transfers to an unproven address: the loser's email
* (and its `email_verified` flag) backfills the survivor only when the
* survivor has no email of its own.
@@ -57,20 +58,147 @@ export interface UserMergeOptions {
readonly dryRun?: boolean;
}
-interface UserMergeCounts {
- identitiesDeduped: number;
- identitiesRepointed: number;
- readStatesDeduped: number;
- readStatesRepointed: number;
- sessionsRepointed: number;
- authSessionsRepointed: number;
- automationsOwnedRepointed: number;
- automationsCreatedRepointed: number;
- scmTokensRepointed: number;
- canonicalEmailBackfilled: number;
- usersDeleted: number;
+const USER_MERGE_COUNT_KEYS = [
+ "identitiesDeduped",
+ "identitiesRepointed",
+ "readStatesDeduped",
+ "readStatesRepointed",
+ "sessionsRepointed",
+ "authSessionsDeleted",
+ "automationsOwnedRepointed",
+ "automationsCreatedRepointed",
+ "scmTokensRepointed",
+ "skillProfileItemsMerged",
+ "skillProfilesDeduped",
+ "skillProfilesRepointed",
+ "roleAssignmentsRemoved",
+ "providerAccountAuthorizationsRepointed",
+ "providerAccountAuthorizationAttemptsRepointed",
+ "keyboardShortcutPreferencesDeduped",
+ "keyboardShortcutPreferencesRepointed",
+ "auditEventsCreated",
+ "canonicalEmailBackfilled",
+ "usersDeleted",
+] as const;
+
+type UserMergeCountKey = (typeof USER_MERGE_COUNT_KEYS)[number];
+type UserMergeCounts = Record;
+
+interface MergeOperation {
+ readonly key: UserMergeCountKey;
+ readonly execute: (db: SqlDatabase, survivorId: string, loserId: string) => SqlStatement;
+ readonly preview: (db: SqlDatabase, survivorId: string, loserId: string) => SqlStatement;
+ readonly subtract?: UserMergeCountKey;
+}
+
+function regularRepoint(key: UserMergeCountKey, table: string, column = "user_id"): MergeOperation {
+ return {
+ key,
+ execute: (db, survivorId, loserId) =>
+ db.prepare(`UPDATE ${table} SET ${column} = ? WHERE ${column} = ?`).bind(survivorId, loserId),
+ preview: (db, _survivorId, loserId) =>
+ db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE ${column} = ?`).bind(loserId),
+ };
}
+function regularDelete(key: UserMergeCountKey, table: string, column = "user_id"): MergeOperation {
+ return {
+ key,
+ execute: (db, _survivorId, loserId) =>
+ db.prepare(`DELETE FROM ${table} WHERE ${column} = ?`).bind(loserId),
+ preview: (db, _survivorId, loserId) =>
+ db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE ${column} = ?`).bind(loserId),
+ };
+}
+
+function dedupeThenRepoint(options: {
+ readonly dedupeKey: UserMergeCountKey;
+ readonly repointKey: UserMergeCountKey;
+ readonly table: string;
+ readonly collision: string;
+}): readonly [MergeOperation, MergeOperation] {
+ return [
+ {
+ key: options.dedupeKey,
+ execute: (db, survivorId, loserId) =>
+ db
+ .prepare(`DELETE FROM ${options.table} WHERE user_id = ? AND ${options.collision}`)
+ .bind(loserId, survivorId),
+ preview: (db, survivorId, loserId) =>
+ db
+ .prepare(
+ `SELECT COUNT(*) AS count FROM ${options.table}
+ WHERE user_id = ? AND ${options.collision}`
+ )
+ .bind(loserId, survivorId),
+ },
+ {
+ ...regularRepoint(options.repointKey, options.table),
+ subtract: options.dedupeKey,
+ },
+ ];
+}
+
+const BEFORE_SKILL_PROFILE_OPERATIONS = [
+ ...dedupeThenRepoint({
+ dedupeKey: "identitiesDeduped",
+ repointKey: "identitiesRepointed",
+ table: "user_identities",
+ collision: `EXISTS (
+ SELECT 1 FROM user_identities AS survivor_identity
+ WHERE survivor_identity.user_id = ?
+ AND survivor_identity.provider = user_identities.provider
+ AND survivor_identity.provider_user_id = user_identities.provider_user_id
+ )`,
+ }),
+ ...dedupeThenRepoint({
+ dedupeKey: "readStatesDeduped",
+ repointKey: "readStatesRepointed",
+ table: "session_read_states",
+ collision: `EXISTS (
+ SELECT 1 FROM session_read_states AS survivor_state
+ WHERE survivor_state.user_id = ?
+ AND survivor_state.session_id = session_read_states.session_id
+ )`,
+ }),
+ regularRepoint("sessionsRepointed", "sessions"),
+ regularDelete("authSessionsDeleted", "auth_sessions", "userId"),
+ regularRepoint("automationsOwnedRepointed", "automations"),
+ regularRepoint("automationsCreatedRepointed", "automations", "created_by"),
+ regularRepoint("scmTokensRepointed", "user_scm_tokens"),
+] as const satisfies readonly MergeOperation[];
+
+const SKILL_PROFILE_OPERATIONS = dedupeThenRepoint({
+ dedupeKey: "skillProfilesDeduped",
+ repointKey: "skillProfilesRepointed",
+ table: "skill_profiles",
+ collision: `EXISTS (
+ SELECT 1 FROM skill_profiles survivor_profile
+ WHERE survivor_profile.user_id = ? AND survivor_profile.name = skill_profiles.name
+ )`,
+});
+
+const FINAL_REPOINT_OPERATIONS = [
+ regularRepoint("providerAccountAuthorizationsRepointed", "model_provider_account_authorizations"),
+ regularRepoint(
+ "providerAccountAuthorizationAttemptsRepointed",
+ "model_provider_account_authorization_attempts"
+ ),
+ ...dedupeThenRepoint({
+ dedupeKey: "keyboardShortcutPreferencesDeduped",
+ repointKey: "keyboardShortcutPreferencesRepointed",
+ table: "keyboard_shortcut_preferences",
+ collision: `EXISTS (SELECT 1 FROM keyboard_shortcut_preferences WHERE user_id = ?)`,
+ }),
+] as const satisfies readonly MergeOperation[];
+
+const TABLE_OPERATIONS = [
+ ...BEFORE_SKILL_PROFILE_OPERATIONS,
+ ...SKILL_PROFILE_OPERATIONS,
+ ...FINAL_REPOINT_OPERATIONS,
+] as const;
+
+/** Counts and identities produced by a user merge or dry-run preview. */
export interface UserMergeResult {
readonly survivorId: string;
readonly loserId: string;
@@ -78,6 +206,9 @@ export interface UserMergeResult {
readonly counts: UserMergeCounts;
}
+/**
+ * Merge a canonical user into a survivor after validating their RBAC assignments.
+ */
export async function mergeUsers(
db: SqlDatabase,
options: UserMergeOptions
@@ -87,9 +218,13 @@ export async function mergeUsers(
throw new UserMergeError("Survivor and loser must be different users");
}
const survivor = await db
- .prepare(`SELECT id, email FROM users WHERE id = ?`)
+ .prepare(`SELECT id, email, suspended_at FROM users WHERE id = ?`)
.bind(survivorId)
- .first<{ id: string; email: string | null }>();
+ .first<{
+ id: string;
+ email: string | null;
+ suspended_at: number | null;
+ }>();
if (!survivor) {
throw new UserMergeError(`Survivor user ${survivorId} not found`);
}
@@ -98,10 +233,45 @@ export async function mergeUsers(
const loser = await db
.prepare(`SELECT id, email, email_verified FROM users WHERE id = ?`)
.bind(loserId)
- .first<{ id: string; email: string | null; email_verified: number }>();
+ .first<{
+ id: string;
+ email: string | null;
+ email_verified: number;
+ }>();
+ if (!loser) {
+ return { survivorId, loserId, dryRun: options.dryRun === true, counts: emptyCounts() };
+ }
const survivorEmail = normalizeEmail(survivor.email);
const loserEmail = normalizeEmail(loser?.email);
+ const [survivorAssignment, loserAssignment] = await db.batch<{
+ role_id: string;
+ role_key: string | null;
+ }>([
+ db
+ .prepare(
+ `SELECT ura.role_id, r.key AS role_key FROM user_role_assignments ura
+ JOIN roles r ON r.id = ura.role_id WHERE ura.user_id = ?`
+ )
+ .bind(survivorId),
+ db
+ .prepare(
+ `SELECT ura.role_id, r.key AS role_key FROM user_role_assignments ura
+ JOIN roles r ON r.id = ura.role_id WHERE ura.user_id = ?`
+ )
+ .bind(loserId),
+ ]);
+ const survivorRole = survivorAssignment.results[0];
+ const loserRole = loserAssignment.results[0];
+ if (!survivorRole || !loserRole) {
+ throw new UserMergeError("Both users must have explicit role assignments before merging");
+ }
+ if (survivorRole && loserRole && survivorRole.role_id !== loserRole.role_id) {
+ throw new UserMergeError("Resolve conflicting user roles before merging");
+ }
+ if (loserRole?.role_key === "owner" && survivor.suspended_at !== null) {
+ throw new UserMergeError("The surviving Owner must be active before merging");
+ }
// The loser's email backfills an email-less survivor after the loser row's
// deletion frees the unique slot; its verification state carries with it.
const backfillEmail = !survivorEmail && loserEmail ? loserEmail : null;
@@ -117,79 +287,59 @@ export async function mergeUsers(
}
const statements: SqlStatement[] = [];
- const track: Partial> = {};
- const add = (key: keyof UserMergeCounts, statement: SqlStatement) => {
+ const track: Partial> = {};
+ const add = (key: UserMergeCountKey, statement: SqlStatement) => {
track[key] = statements.length;
statements.push(statement);
};
+ const addOperations = (operations: readonly MergeOperation[]) => {
+ for (const operation of operations) {
+ add(operation.key, operation.execute(db, survivorId, loserId));
+ }
+ };
// Dedup before re-pointing: drop loser rows whose target slot the survivor
// already occupies (identities under idx_user_identities_provider; read
// states routinely, where both split rows read the same session).
+ addOperations(BEFORE_SKILL_PROFILE_OPERATIONS);
+
+ // Merge items before deleting colliding skill profiles.
add(
- "identitiesDeduped",
+ "skillProfileItemsMerged",
db
.prepare(
- `DELETE FROM user_identities
- WHERE user_id = ?
- AND EXISTS (
- SELECT 1 FROM user_identities AS survivor_identity
- WHERE survivor_identity.user_id = ?
- AND survivor_identity.provider = user_identities.provider
- AND survivor_identity.provider_user_id = user_identities.provider_user_id
- )`
+ `INSERT OR IGNORE INTO skill_profile_items (profile_id, skill_id)
+ SELECT survivor_profile.id, loser_item.skill_id
+ FROM skill_profiles loser_profile
+ JOIN skill_profiles survivor_profile
+ ON survivor_profile.user_id = ? AND survivor_profile.name = loser_profile.name
+ JOIN skill_profile_items loser_item ON loser_item.profile_id = loser_profile.id
+ WHERE loser_profile.user_id = ?`
)
- .bind(loserId, survivorId)
+ .bind(survivorId, loserId)
);
+ addOperations(SKILL_PROFILE_OPERATIONS);
+
+ // Preserve the survivor's RBAC assignment before deleting the loser.
add(
- "identitiesRepointed",
- db.prepare(`UPDATE user_identities SET user_id = ? WHERE user_id = ?`).bind(survivorId, loserId)
+ "roleAssignmentsRemoved",
+ db.prepare("DELETE FROM user_role_assignments WHERE user_id = ?").bind(loserId)
);
+ addOperations(FINAL_REPOINT_OPERATIONS);
+
+ // Record the merge before deleting the user so the snapshots remain explicit.
add(
- "readStatesDeduped",
+ "auditEventsCreated",
db
.prepare(
- `DELETE FROM session_read_states
- WHERE user_id = ?
- AND EXISTS (
- SELECT 1 FROM session_read_states AS survivor_state
- WHERE survivor_state.user_id = ?
- AND survivor_state.session_id = session_read_states.session_id
- )`
+ `INSERT INTO authorization_audit_events
+ (id, occurred_at, request_id, principal_kind,
+ actor_service_snapshot, action, resource_type, resource_id,
+ target_user_id_snapshot, reason_code)
+ VALUES (?, ?, 'user-merge', 'service', 'control-plane',
+ 'workspace.user_merged', 'user', ?, ?, 'operator_merge')`
)
- .bind(loserId, survivorId)
- );
- add(
- "readStatesRepointed",
- db
- .prepare(`UPDATE session_read_states SET user_id = ? WHERE user_id = ?`)
- .bind(survivorId, loserId)
- );
- add(
- "sessionsRepointed",
- db.prepare(`UPDATE sessions SET user_id = ? WHERE user_id = ?`).bind(survivorId, loserId)
- );
- // Browser sessions re-point (FK → users): the person stays signed in and
- // is simply the survivor from the next request on.
- add(
- "authSessionsRepointed",
- db.prepare(`UPDATE auth_sessions SET userId = ? WHERE userId = ?`).bind(survivorId, loserId)
- );
- add(
- "automationsOwnedRepointed",
- db.prepare(`UPDATE automations SET user_id = ? WHERE user_id = ?`).bind(survivorId, loserId)
- );
- // Value-conditional: created_by is compared for exact equality with the
- // loser's canonical id, so legacy GitHub numeric ids pass through.
- add(
- "automationsCreatedRepointed",
- db
- .prepare(`UPDATE automations SET created_by = ? WHERE created_by = ?`)
- .bind(survivorId, loserId)
- );
- add(
- "scmTokensRepointed",
- db.prepare(`UPDATE user_scm_tokens SET user_id = ? WHERE user_id = ?`).bind(survivorId, loserId)
+ .bind(crypto.randomUUID(), Date.now(), survivorId, loserId)
);
add("usersDeleted", db.prepare(`DELETE FROM users WHERE id = ?`).bind(loserId));
@@ -213,10 +363,10 @@ export async function mergeUsers(
);
}
- const results = await db.batch(statements);
+ const results: SqlResult[] = await db.batch(statements);
const counts = emptyCounts();
- for (const [key, index] of Object.entries(track) as [keyof UserMergeCounts, number][]) {
+ for (const [key, index] of Object.entries(track) as [UserMergeCountKey, number][]) {
counts[key] = results[index]?.meta.changes ?? 0;
}
if (loser) {
@@ -228,19 +378,7 @@ export async function mergeUsers(
}
function emptyCounts(): UserMergeCounts {
- return {
- identitiesDeduped: 0,
- identitiesRepointed: 0,
- readStatesDeduped: 0,
- readStatesRepointed: 0,
- sessionsRepointed: 0,
- authSessionsRepointed: 0,
- automationsOwnedRepointed: 0,
- automationsCreatedRepointed: 0,
- scmTokensRepointed: 0,
- canonicalEmailBackfilled: 0,
- usersDeleted: 0,
- };
+ return Object.fromEntries(USER_MERGE_COUNT_KEYS.map((key) => [key, 0])) as UserMergeCounts;
}
async function previewCounts(
@@ -249,48 +387,34 @@ async function previewCounts(
loserId: string,
backfillEmail: string | null
): Promise {
- const [
- identitiesDeduped,
- identities,
- readStatesDeduped,
- readStates,
- sessions,
- authSessions,
- automationsOwned,
- automationsCreated,
- scmTokens,
- users,
- ] = await db.batch<{ count: number }>([
+ const operationResults = await db.batch<{ count: number }>(
+ TABLE_OPERATIONS.map((operation) => operation.preview(db, survivorId, loserId))
+ );
+ const operationCounts = emptyCounts();
+ for (const [index, operation] of TABLE_OPERATIONS.entries()) {
+ const total = operationResults[index]?.results[0]?.count ?? 0;
+ operationCounts[operation.key] =
+ total - (operation.subtract ? operationCounts[operation.subtract] : 0);
+ }
+
+ const [skillProfileItemsMerged, roleAssignments, users] = await db.batch<{ count: number }>([
db
.prepare(
- `SELECT COUNT(*) AS count FROM user_identities
- WHERE user_id = ?
- AND EXISTS (
- SELECT 1 FROM user_identities AS survivor_identity
- WHERE survivor_identity.user_id = ?
- AND survivor_identity.provider = user_identities.provider
- AND survivor_identity.provider_user_id = user_identities.provider_user_id
+ `SELECT COUNT(*) AS count FROM skill_profile_items loser_item
+ JOIN skill_profiles loser_profile ON loser_profile.id = loser_item.profile_id
+ JOIN skill_profiles survivor_profile
+ ON survivor_profile.user_id = ? AND survivor_profile.name = loser_profile.name
+ WHERE loser_profile.user_id = ?
+ AND NOT EXISTS (
+ SELECT 1 FROM skill_profile_items survivor_item
+ WHERE survivor_item.profile_id = survivor_profile.id
+ AND survivor_item.skill_id = loser_item.skill_id
)`
)
- .bind(loserId, survivorId),
- db.prepare(`SELECT COUNT(*) AS count FROM user_identities WHERE user_id = ?`).bind(loserId),
+ .bind(survivorId, loserId),
db
- .prepare(
- `SELECT COUNT(*) AS count FROM session_read_states
- WHERE user_id = ?
- AND EXISTS (
- SELECT 1 FROM session_read_states AS survivor_state
- WHERE survivor_state.user_id = ?
- AND survivor_state.session_id = session_read_states.session_id
- )`
- )
- .bind(loserId, survivorId),
- db.prepare(`SELECT COUNT(*) AS count FROM session_read_states WHERE user_id = ?`).bind(loserId),
- db.prepare(`SELECT COUNT(*) AS count FROM sessions WHERE user_id = ?`).bind(loserId),
- db.prepare(`SELECT COUNT(*) AS count FROM auth_sessions WHERE userId = ?`).bind(loserId),
- db.prepare(`SELECT COUNT(*) AS count FROM automations WHERE user_id = ?`).bind(loserId),
- db.prepare(`SELECT COUNT(*) AS count FROM automations WHERE created_by = ?`).bind(loserId),
- db.prepare(`SELECT COUNT(*) AS count FROM user_scm_tokens WHERE user_id = ?`).bind(loserId),
+ .prepare(`SELECT COUNT(*) AS count FROM user_role_assignments WHERE user_id = ?`)
+ .bind(loserId),
db.prepare(`SELECT COUNT(*) AS count FROM users WHERE id = ?`).bind(loserId),
]);
@@ -311,16 +435,10 @@ async function previewCounts(
}
return {
- ...emptyCounts(),
- identitiesDeduped: count(identitiesDeduped),
- identitiesRepointed: count(identities) - count(identitiesDeduped),
- readStatesDeduped: count(readStatesDeduped),
- readStatesRepointed: count(readStates) - count(readStatesDeduped),
- sessionsRepointed: count(sessions),
- authSessionsRepointed: count(authSessions),
- automationsOwnedRepointed: count(automationsOwned),
- automationsCreatedRepointed: count(automationsCreated),
- scmTokensRepointed: count(scmTokens),
+ ...operationCounts,
+ skillProfileItemsMerged: count(skillProfileItemsMerged),
+ roleAssignmentsRemoved: count(roleAssignments),
+ auditEventsCreated: count(users),
canonicalEmailBackfilled,
usersDeleted: count(users),
};
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 dc3f54a22..df32d4b14 100644
--- a/packages/control-plane/test/integration/browser-auth-callback.test.ts
+++ b/packages/control-plane/test/integration/browser-auth-callback.test.ts
@@ -317,6 +317,19 @@ describe("browser auth callback", () => {
.bind(session.user.id)
.first<{ id: string }>();
expect(account).not.toBeNull();
+ await expect(
+ env.DB.prepare(
+ `SELECT r.key FROM user_role_assignments ura
+ JOIN roles r ON r.id = ura.role_id WHERE ura.user_id = ?`
+ )
+ .bind(session.user.id)
+ .first()
+ ).resolves.toEqual({ key: "member" });
+ await expect(
+ env.DB.prepare(
+ "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.owner_bootstrapped'"
+ ).first()
+ ).resolves.toEqual({ count: 0 });
const enrichment = await resolveGitHubEnrichmentForRequest(
env,
diff --git a/packages/control-plane/test/integration/browser-auth.test.ts b/packages/control-plane/test/integration/browser-auth.test.ts
index 2aaa7de11..9eb77f58b 100644
--- a/packages/control-plane/test/integration/browser-auth.test.ts
+++ b/packages/control-plane/test/integration/browser-auth.test.ts
@@ -29,6 +29,7 @@ const EXPECTED_COLUMNS: Record = {
["created_at", "INTEGER", 1, 0],
["updated_at", "INTEGER", 1, 0],
["email_verified", "INTEGER", 1, 0],
+ ["suspended_at", "INTEGER", 0, 0],
],
user_identities: [
["id", "TEXT", 0, 1],
diff --git a/packages/control-plane/test/integration/cleanup.ts b/packages/control-plane/test/integration/cleanup.ts
index 98b5ebf43..10f84a361 100644
--- a/packages/control-plane/test/integration/cleanup.ts
+++ b/packages/control-plane/test/integration/cleanup.ts
@@ -6,6 +6,6 @@ import { env } from "cloudflare:test";
*/
export async function cleanD1Tables(): Promise {
await env.DB.exec(
- "DELETE FROM auth_verifications; DELETE FROM auth_sessions; DELETE FROM automation_model_provider_auth; DELETE FROM automation_slack_channels; DELETE FROM automation_runs; DELETE FROM automation_invocations; DELETE FROM automation_repositories; DELETE FROM automation_environments; DELETE FROM automations; DELETE FROM pr_autofix_feedback; DELETE FROM session_model_provider_auth; DELETE FROM session_read_states; DELETE FROM session_pull_requests; DELETE FROM session_repositories; DELETE FROM child_admission_leases; DELETE FROM session_skill_revisions; DELETE FROM session_skill_manifests; DELETE FROM sessions; DELETE FROM model_provider_account_authorization_attempts; DELETE FROM model_provider_account_authorizations; DELETE FROM model_provider_account_defaults; DELETE FROM model_provider_account_credentials; DELETE FROM model_provider_accounts; DELETE FROM skill_profile_items; DELETE FROM skill_profiles; DELETE FROM skill_assignments; DELETE FROM skill_import_sources; DELETE FROM skill_revision_files; DELETE FROM skill_revisions; DELETE FROM skills; UPDATE skills_catalog_state SET generation = 0 WHERE singleton = 1; DELETE FROM user_scm_tokens; DELETE FROM repo_metadata; DELETE FROM repo_secrets; DELETE FROM global_secrets; DELETE FROM commit_signing_configuration; DELETE FROM integration_settings; DELETE FROM integration_repo_settings; DELETE FROM integration_environment_settings; DELETE FROM model_preferences; DELETE FROM mcp_servers; DELETE FROM keyboard_shortcut_preferences; DELETE FROM user_identities; DELETE FROM users; DELETE FROM image_builds; DELETE FROM environment_secrets; DELETE FROM environment_repositories; DELETE FROM environments;"
+ "DELETE FROM auth_verifications; DELETE FROM auth_sessions; DELETE FROM automation_model_provider_auth; DELETE FROM automation_slack_channels; DELETE FROM automation_runs; DELETE FROM automation_invocations; DELETE FROM automation_repositories; DELETE FROM automation_environments; DELETE FROM automations; DELETE FROM pr_autofix_feedback; DELETE FROM session_model_provider_auth; DELETE FROM session_read_states; DELETE FROM session_pull_requests; DELETE FROM session_repositories; DELETE FROM child_admission_leases; DELETE FROM session_skill_revisions; DELETE FROM session_skill_manifests; DELETE FROM sessions; DELETE FROM model_provider_account_authorization_attempts; DELETE FROM model_provider_account_authorizations; DELETE FROM model_provider_account_defaults; DELETE FROM model_provider_account_credentials; DELETE FROM model_provider_accounts; DELETE FROM skill_profile_items; DELETE FROM skill_profiles; DELETE FROM skill_assignments; DELETE FROM skill_import_sources; DELETE FROM skill_revision_files; DELETE FROM skill_revisions; DELETE FROM skills; UPDATE skills_catalog_state SET generation = 0 WHERE singleton = 1; DELETE FROM user_scm_tokens; DELETE FROM repo_metadata; DELETE FROM repo_secrets; DELETE FROM global_secrets; DELETE FROM commit_signing_configuration; DELETE FROM integration_settings; DELETE FROM integration_repo_settings; DELETE FROM model_preferences; DELETE FROM mcp_servers; DELETE FROM keyboard_shortcut_preferences; DELETE FROM authorization_audit_events; DELETE FROM user_role_assignments; DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE is_system = 0); DELETE FROM roles WHERE is_system = 0; DELETE FROM user_identities; DELETE FROM users; DELETE FROM image_builds; DELETE FROM environment_secrets; DELETE FROM environment_repositories; DELETE FROM environments;"
);
}
diff --git a/packages/control-plane/test/integration/migration-0071-rbac-foundation.test.ts b/packages/control-plane/test/integration/migration-0071-rbac-foundation.test.ts
new file mode 100644
index 000000000..2c98a72e7
--- /dev/null
+++ b/packages/control-plane/test/integration/migration-0071-rbac-foundation.test.ts
@@ -0,0 +1,134 @@
+import { env } from "cloudflare:test";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { cleanD1Tables } from "./cleanup";
+
+const migration = () => {
+ const entry = env.TEST_MIGRATIONS.find((candidate) => candidate.name.startsWith("0071"));
+ if (!entry) throw new Error("Migration 0071 not found in TEST_MIGRATIONS");
+ return entry;
+};
+
+async function tableColumns(table: string): Promise {
+ const result = await env.DB.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();
+ return result.results.map((column) => column.name);
+}
+
+async function restoreMigration(): Promise {
+ if (!(await tableColumns("users")).includes("suspended_at")) {
+ await env.DB.batch(migration().queries.map((query) => env.DB.prepare(query)));
+ }
+}
+
+beforeEach(cleanD1Tables);
+afterEach(async () => {
+ await restoreMigration();
+ await cleanD1Tables();
+});
+
+describe("migration 0071: RBAC foundation", () => {
+ it("backfills existing users before enabling Member defaults", async () => {
+ await env.DB.exec(`
+ DROP TRIGGER assign_default_role_after_user_insert;
+ DROP TABLE authorization_audit_events;
+ DROP TABLE user_role_assignments;
+ DROP TABLE role_permissions;
+ DROP TABLE roles;
+ ALTER TABLE users DROP COLUMN suspended_at;
+ `);
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO users
+ (id, display_name, email, email_verified, avatar_url, created_at, updated_at)
+ VALUES
+ ('11111111111111111111111111111111', 'Existing One', 'one@example.com', 1, NULL, 100, 100),
+ ('22222222222222222222222222222222', 'Existing Two', 'two@example.com', 1, NULL, 200, 200)`
+ ),
+ env.DB.prepare(
+ `INSERT INTO sessions
+ (id, repo_owner, repo_name, status, created_at, updated_at, user_id)
+ VALUES
+ ('existing-session', 'acme', 'repo', 'completed', 300, 300,
+ '11111111111111111111111111111111'),
+ ('anonymous-session', 'acme', 'repo', 'completed', 400, 400, NULL)`
+ ),
+ env.DB.prepare(
+ `INSERT INTO user_identities
+ (id, user_id, provider, provider_user_id, provider_issuer, created_at, updated_at)
+ VALUES ('existing-identity', '11111111111111111111111111111111',
+ 'github', 'legacy-github-id', 'https://github.com', 100, 100)`
+ ),
+ env.DB.prepare(
+ `INSERT INTO automations
+ (id, name, instructions, model, created_by, user_id, created_at, updated_at)
+ VALUES ('existing-automation', 'Existing', 'Run', 'anthropic/claude-sonnet-4-6',
+ 'legacy-github-id', NULL, 100, 100)`
+ ),
+ ]);
+
+ await env.DB.batch(migration().queries.map((query) => env.DB.prepare(query)));
+
+ expect(
+ await env.DB.prepare(
+ `SELECT u.id, u.suspended_at, r.key AS role_key
+ FROM users u
+ JOIN user_role_assignments ura ON ura.user_id = u.id
+ JOIN roles r ON r.id = ura.role_id
+ ORDER BY u.id`
+ ).all()
+ ).toMatchObject({
+ results: [
+ {
+ id: "11111111111111111111111111111111",
+ suspended_at: null,
+ role_key: "administrator",
+ },
+ {
+ id: "22222222222222222222222222222222",
+ suspended_at: null,
+ role_key: "administrator",
+ },
+ ],
+ });
+ expect(
+ await env.DB.prepare(
+ "SELECT user_id FROM automations WHERE id = 'existing-automation'"
+ ).first()
+ ).toEqual({ user_id: "11111111111111111111111111111111" });
+ expect(await tableColumns("roles")).toEqual([
+ "id",
+ "key",
+ "name",
+ "normalized_name",
+ "description",
+ "is_system",
+ ]);
+ expect(await tableColumns("user_role_assignments")).toEqual(["user_id", "role_id"]);
+ expect(await tableColumns("authorization_audit_events")).toEqual([
+ "id",
+ "occurred_at",
+ "request_id",
+ "principal_kind",
+ "actor_user_id_snapshot",
+ "actor_service_snapshot",
+ "action",
+ "resource_type",
+ "resource_id",
+ "target_user_id_snapshot",
+ "reason_code",
+ ]);
+
+ await env.DB.prepare(
+ `INSERT INTO users
+ (id, display_name, email, email_verified, avatar_url, created_at, updated_at)
+ VALUES ('33333333333333333333333333333333', 'New User', NULL, 0, NULL, 500, 500)`
+ ).run();
+ expect(
+ await env.DB.prepare(
+ `SELECT r.key FROM user_role_assignments ura
+ JOIN roles r ON r.id = ura.role_id
+ WHERE ura.user_id = '33333333333333333333333333333333'`
+ ).first()
+ ).toEqual({ key: "member" });
+ expect((await env.DB.prepare("PRAGMA foreign_key_check").all()).results).toEqual([]);
+ });
+});
diff --git a/packages/control-plane/test/integration/rbac-foundation.test.ts b/packages/control-plane/test/integration/rbac-foundation.test.ts
new file mode 100644
index 000000000..bb73a61d8
--- /dev/null
+++ b/packages/control-plane/test/integration/rbac-foundation.test.ts
@@ -0,0 +1,27 @@
+import { env } from "cloudflare:test";
+import {
+ BUILT_IN_ROLE_REGISTRY,
+ PERMISSION_IDS,
+ permissionsForBuiltInRole,
+} from "@open-inspect/shared/rbac";
+import { describe, expect, it } from "vitest";
+
+describe("RBAC foundation migration", () => {
+ it("seeds built-in roles without persisting their code-owned permissions", async () => {
+ const roles = await env.DB.prepare(
+ "SELECT id, key FROM roles WHERE is_system = 1 ORDER BY key"
+ ).all<{ id: string; key: string }>();
+
+ expect(roles.results).toEqual(
+ Object.values(BUILT_IN_ROLE_REGISTRY).sort((left, right) => left.key.localeCompare(right.key))
+ );
+
+ expect(
+ await env.DB.prepare(
+ `SELECT COUNT(*) AS count FROM role_permissions rp
+ JOIN roles r ON r.id = rp.role_id WHERE r.is_system = 1`
+ ).first()
+ ).toEqual({ count: 0 });
+ expect(permissionsForBuiltInRole("owner")).toHaveLength(PERMISSION_IDS.length);
+ });
+});
diff --git a/packages/control-plane/test/integration/session-read-state.test.ts b/packages/control-plane/test/integration/session-read-state.test.ts
index 6470fba0b..b8b79f46e 100644
--- a/packages/control-plane/test/integration/session-read-state.test.ts
+++ b/packages/control-plane/test/integration/session-read-state.test.ts
@@ -284,6 +284,9 @@ describe("session read state", () => {
action: "mark_latest_message_read",
});
+ await env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?")
+ .bind("deleted-user")
+ .run();
await env.DB.prepare("DELETE FROM users WHERE id = ?").bind("deleted-user").run();
expect(await env.DB.prepare("SELECT * FROM session_read_states").all()).toMatchObject({
results: [],
diff --git a/packages/control-plane/test/integration/user-merge.test.ts b/packages/control-plane/test/integration/user-merge.test.ts
index cb94043b2..43d6272c1 100644
--- a/packages/control-plane/test/integration/user-merge.test.ts
+++ b/packages/control-plane/test/integration/user-merge.test.ts
@@ -61,6 +61,15 @@ async function insertScmToken(providerUserId: string, userId: string) {
.run();
}
+async function insertSkillProfile(id: string, userId: string, name: string) {
+ await env.DB.prepare(
+ `INSERT INTO skill_profiles (id, user_id, name, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?)`
+ )
+ .bind(id, userId, name, SEED_NOW_MS, SEED_NOW_MS)
+ .run();
+}
+
beforeEach(async () => {
await cleanD1Tables();
});
@@ -79,6 +88,7 @@ describe("mergeUsers", () => {
await insertSession("session-loser", LOSER);
await insertAutomation("auto-1", LOSER, LOSER);
await insertScmToken("583231", LOSER);
+ await insertSkillProfile("profile-loser", LOSER, "Personal profile");
await insertAuthSession({ id: "authsess-loser", userId: LOSER });
// Survivor: the email-owning row the user already signs into.
await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com", emailVerified: 1 });
@@ -100,10 +110,11 @@ describe("mergeUsers", () => {
expect(result.counts).toMatchObject({
identitiesRepointed: 1,
sessionsRepointed: 1,
- authSessionsRepointed: 1,
+ authSessionsDeleted: 1,
automationsOwnedRepointed: 1,
automationsCreatedRepointed: 1,
scmTokensRepointed: 1,
+ skillProfilesRepointed: 1,
readStatesDeduped: 1,
readStatesRepointed: 1,
usersDeleted: 1,
@@ -119,12 +130,12 @@ describe("mergeUsers", () => {
user_id: string;
}>()
).toEqual({ user_id: SURVIVOR });
- // The loser's browser session survives, re-keyed to the survivor.
+ // Bearer sessions issued to the loser are invalidated, never re-keyed.
expect(
await env.DB.prepare(`SELECT userId FROM auth_sessions WHERE id = 'authsess-loser'`).first<{
userId: string;
}>()
- ).toEqual({ userId: SURVIVOR });
+ ).toBeNull();
expect(
await env.DB.prepare(
`SELECT user_id, created_by FROM automations WHERE id = 'auto-1'`
@@ -133,6 +144,9 @@ describe("mergeUsers", () => {
created_by: string;
}>()
).toEqual({ user_id: SURVIVOR, created_by: SURVIVOR });
+ expect(
+ await env.DB.prepare(`SELECT user_id FROM skill_profiles WHERE id = 'profile-loser'`).first()
+ ).toEqual({ user_id: SURVIVOR });
// Read-state dedup kept the survivor's row on the shared session.
expect(
await env.DB.prepare(
@@ -144,6 +158,19 @@ describe("mergeUsers", () => {
).toEqual({ last_read_message_id: "msg-survivor" });
expect(await getUserRow(LOSER)).toBeNull();
expect(await countTableRows("users")).toBe(1);
+ expect(
+ await env.DB.prepare(
+ `SELECT principal_kind, actor_user_id_snapshot, actor_service_snapshot,
+ resource_id, target_user_id_snapshot
+ FROM authorization_audit_events WHERE action = 'workspace.user_merged'`
+ ).first()
+ ).toEqual({
+ principal_kind: "service",
+ actor_user_id_snapshot: null,
+ actor_service_snapshot: "control-plane",
+ resource_id: SURVIVOR,
+ target_user_id_snapshot: LOSER,
+ });
});
it("backfills the loser's email onto an email-less survivor, carrying verification as-was", async () => {
@@ -201,7 +228,7 @@ describe("mergeUsers", () => {
expect(await getUserRow(SURVIVOR)).toMatchObject({ email: null });
const executed = await mergeUsers(env.DB, { survivorId: SURVIVOR, loserId: LOSER });
- expect(executed.counts.canonicalEmailBackfilled).toBe(preview.counts.canonicalEmailBackfilled);
+ expect(executed.counts).toEqual(preview.counts);
});
it("leaves non-canonical created_by values (legacy GitHub numeric ids) untouched", async () => {
@@ -219,6 +246,99 @@ describe("mergeUsers", () => {
).toEqual({ created_by: "583231", user_id: SURVIVOR });
});
+ it("keeps preview and execution counts aligned for newer user-owned records", async () => {
+ await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
+ await insertCanonicalUser({ id: LOSER, email: null });
+ const authorizationId = "c".repeat(64);
+ const attemptId = "d".repeat(64);
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO model_provider_account_authorizations (
+ id, user_id, provider, operation, display_name, next_poll_at,
+ expires_at, state, created_at, updated_at
+ ) VALUES (?, ?, 'openai', 'create', 'Personal', ?, ?, 'initiating', ?, ?)`
+ ).bind(authorizationId, LOSER, SEED_NOW_MS, SEED_NOW_MS + 60_000, SEED_NOW_MS, SEED_NOW_MS),
+ env.DB.prepare(
+ `INSERT INTO model_provider_account_authorization_attempts
+ (id, user_id, attempted_at) VALUES (?, ?, ?)`
+ ).bind(attemptId, LOSER, SEED_NOW_MS),
+ env.DB.prepare(
+ `INSERT INTO keyboard_shortcut_preferences (user_id, shortcuts, updated_at)
+ VALUES (?, '{}', ?)`
+ ).bind(LOSER, SEED_NOW_MS),
+ ]);
+
+ const preview = await mergeUsers(env.DB, {
+ survivorId: SURVIVOR,
+ loserId: LOSER,
+ dryRun: true,
+ });
+
+ expect(preview.counts).toMatchObject({
+ providerAccountAuthorizationsRepointed: 1,
+ providerAccountAuthorizationAttemptsRepointed: 1,
+ keyboardShortcutPreferencesDeduped: 0,
+ keyboardShortcutPreferencesRepointed: 1,
+ });
+ expect(
+ await env.DB.prepare(`SELECT user_id FROM model_provider_account_authorizations WHERE id = ?`)
+ .bind(authorizationId)
+ .first()
+ ).toEqual({ user_id: LOSER });
+
+ const result = await mergeUsers(env.DB, { survivorId: SURVIVOR, loserId: LOSER });
+
+ expect(result.counts).toEqual(preview.counts);
+ expect(
+ await env.DB.prepare(`SELECT user_id FROM model_provider_account_authorizations WHERE id = ?`)
+ .bind(authorizationId)
+ .first()
+ ).toEqual({ user_id: SURVIVOR });
+ expect(
+ await env.DB.prepare(
+ `SELECT user_id FROM model_provider_account_authorization_attempts WHERE id = ?`
+ )
+ .bind(attemptId)
+ .first()
+ ).toEqual({ user_id: SURVIVOR });
+ expect(
+ await env.DB.prepare(`SELECT shortcuts FROM keyboard_shortcut_preferences WHERE user_id = ?`)
+ .bind(SURVIVOR)
+ .first()
+ ).toEqual({ shortcuts: "{}" });
+ });
+
+ it("keeps keyboard preference collision preview and execution counts aligned", async () => {
+ await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
+ await insertCanonicalUser({ id: LOSER, email: null });
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO keyboard_shortcut_preferences (user_id, shortcuts, updated_at)
+ VALUES (?, '{"survivor":true}', ?)`
+ ).bind(SURVIVOR, SEED_NOW_MS),
+ env.DB.prepare(
+ `INSERT INTO keyboard_shortcut_preferences (user_id, shortcuts, updated_at)
+ VALUES (?, '{"loser":true}', ?)`
+ ).bind(LOSER, SEED_NOW_MS),
+ ]);
+
+ const preview = await mergeUsers(env.DB, {
+ survivorId: SURVIVOR,
+ loserId: LOSER,
+ dryRun: true,
+ });
+ const result = await mergeUsers(env.DB, { survivorId: SURVIVOR, loserId: LOSER });
+
+ expect(preview.counts.keyboardShortcutPreferencesDeduped).toBe(1);
+ expect(preview.counts.keyboardShortcutPreferencesRepointed).toBe(0);
+ expect(result.counts).toEqual(preview.counts);
+ expect(
+ await env.DB.prepare(`SELECT shortcuts FROM keyboard_shortcut_preferences WHERE user_id = ?`)
+ .bind(SURVIVOR)
+ .first()
+ ).toEqual({ shortcuts: '{"survivor":true}' });
+ });
+
it("is idempotent: re-running after a completed merge is a zero-count no-op", async () => {
await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
await insertCanonicalUser({ id: LOSER, email: null });
diff --git a/packages/control-plane/test/integration/user-store.test.ts b/packages/control-plane/test/integration/user-store.test.ts
index f5596c5ce..53351771d 100644
--- a/packages/control-plane/test/integration/user-store.test.ts
+++ b/packages/control-plane/test/integration/user-store.test.ts
@@ -166,6 +166,28 @@ describe("UserStore", () => {
expect(user!.updatedAt).toBeGreaterThanOrEqual(beforeUpdate!.updatedAt);
});
+ it("does not repair a missing role assignment during identity resolution", async () => {
+ const first = await store.resolveOrCreateUser({
+ provider: "github",
+ providerUserId: "missing-assignment",
+ });
+ await env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?")
+ .bind(first.id)
+ .run();
+
+ await store.resolveOrCreateUser({
+ provider: "github",
+ providerUserId: "missing-assignment",
+ });
+
+ const assignment = await env.DB.prepare(
+ "SELECT role_id FROM user_role_assignments WHERE user_id = ?"
+ )
+ .bind(first.id)
+ .first();
+ expect(assignment).toBeNull();
+ });
+
it("links new identity to existing user by matching email", async () => {
const github = await store.resolveOrCreateUser({
provider: "github",
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 4a66e00a6..743f77114 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -50,6 +50,10 @@
"import": "./dist/user-id.js",
"types": "./dist/user-id.d.ts"
},
+ "./rbac": {
+ "import": "./dist/rbac.js",
+ "types": "./dist/rbac.d.ts"
+ },
"./browser-auth-routes": {
"import": "./dist/browser-auth-routes.js",
"types": "./dist/browser-auth-routes.d.ts"
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index b4d5fc30c..dc9890381 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -20,3 +20,4 @@ export * from "./browser-auth-routes";
export * from "./sign-in-provider";
export * from "./slack";
export * from "./pull-request-tool";
+export * from "./rbac";
diff --git a/packages/shared/src/rbac.test.ts b/packages/shared/src/rbac.test.ts
new file mode 100644
index 000000000..7e3853a9c
--- /dev/null
+++ b/packages/shared/src/rbac.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it } from "vitest";
+import {
+ BUILT_IN_ROLE_KEYS,
+ BUILT_IN_ROLE_REGISTRY,
+ PERMISSION_IDS,
+ SCOPED_PERMISSION_PAIRS,
+ effectiveAuthorizationSchema,
+ permissionsForBuiltInRole,
+ resolveScopedPermission,
+ replaceMemberRoleInputSchema,
+ replaceMemberStatusInputSchema,
+} from "./rbac";
+
+describe("RBAC registry", () => {
+ it("defines stable built-in role identities", () => {
+ expect(BUILT_IN_ROLE_REGISTRY).toEqual({
+ owner: {
+ id: "role_builtin_owner",
+ key: "owner",
+ },
+ administrator: {
+ id: "role_builtin_administrator",
+ key: "administrator",
+ },
+ member: {
+ id: "role_builtin_member",
+ key: "member",
+ },
+ viewer: {
+ id: "role_builtin_viewer",
+ key: "viewer",
+ },
+ });
+ expect(BUILT_IN_ROLE_KEYS).toEqual(
+ Object.values(BUILT_IN_ROLE_REGISTRY).map((role) => role.key)
+ );
+ expect(new Set(Object.values(BUILT_IN_ROLE_REGISTRY).map((role) => role.id)).size).toBe(
+ BUILT_IN_ROLE_KEYS.length
+ );
+ });
+
+ it("contains unique, sorted permission identifiers", () => {
+ expect(PERMISSION_IDS).toHaveLength(42);
+ expect(new Set(PERMISSION_IDS).size).toBe(PERMISSION_IDS.length);
+ expect(PERMISSION_IDS).toEqual([...PERMISSION_IDS].sort());
+ });
+
+ it("owns every any/own permission pair and resolves any before own", () => {
+ const scopedPermissions = Object.values(SCOPED_PERMISSION_PAIRS).flatMap(({ any, own }) => [
+ any,
+ own,
+ ]);
+ expect(new Set(scopedPermissions)).toEqual(
+ new Set(PERMISSION_IDS.filter((permission) => /\.(any|own)$/.test(permission)))
+ );
+ expect(
+ resolveScopedPermission("automations.manage", [
+ "automations.manage.own",
+ "automations.manage.any",
+ ])
+ ).toBe("any");
+ expect(resolveScopedPermission("automations.manage", ["automations.manage.own"])).toBe("own");
+ expect(resolveScopedPermission("automations.manage", [])).toBeNull();
+ });
+
+ it("assigns every permission explicitly to Owner", () => {
+ expect(permissionsForBuiltInRole("owner")).toEqual(PERMISSION_IDS);
+ });
+
+ it("reserves ownership transfer for Owner", () => {
+ for (const role of BUILT_IN_ROLE_KEYS) {
+ expect(permissionsForBuiltInRole(role).includes("workspace.transfer_ownership")).toBe(
+ role === "owner"
+ );
+ }
+ });
+
+ it("grants Members workspace-wide session operations", () => {
+ const permissions = permissionsForBuiltInRole("member");
+ expect(permissions).toEqual(
+ expect.arrayContaining([
+ "sessions.read",
+ "sessions.collaborate",
+ "sessions.create",
+ "sessions.lifecycle",
+ "sessions.sandbox_access",
+ "sessions.delete",
+ ])
+ );
+ });
+
+ it("grants workspace analytics to Members and Viewers", () => {
+ expect(permissionsForBuiltInRole("member")).toContain("analytics.read");
+ expect(permissionsForBuiltInRole("viewer")).toContain("analytics.read");
+ });
+
+ it("makes Member a superset of Viewer", () => {
+ expect(permissionsForBuiltInRole("member")).toEqual(
+ expect.arrayContaining(permissionsForBuiltInRole("viewer"))
+ );
+ });
+
+ it("reserves personal profile management for Member and above", () => {
+ expect(permissionsForBuiltInRole("member")).toContain("skill_profiles.manage_own");
+ expect(permissionsForBuiltInRole("viewer")).not.toContain("skill_profiles.manage_own");
+ });
+
+ it("requires an assigned role and uses suspension timestamps in public contracts", () => {
+ expect(
+ effectiveAuthorizationSchema.parse({
+ userId: "11111111111111111111111111111111",
+ suspendedAt: null,
+ role: { id: "role_builtin_member", key: "member", name: "Member" },
+ permissions: [],
+ })
+ ).toMatchObject({ suspendedAt: null });
+ expect(() =>
+ effectiveAuthorizationSchema.parse({
+ userId: "11111111111111111111111111111111",
+ suspendedAt: null,
+ role: null,
+ permissions: [],
+ })
+ ).toThrow();
+ expect(replaceMemberRoleInputSchema.parse({ roleId: "role_custom" })).toEqual({
+ roleId: "role_custom",
+ });
+ expect(replaceMemberStatusInputSchema.parse({ suspended: true })).toEqual({ suspended: true });
+ expect(() =>
+ replaceMemberStatusInputSchema.parse({ suspended: true, suspendedAt: 123 })
+ ).toThrow();
+ });
+});
diff --git a/packages/shared/src/rbac.ts b/packages/shared/src/rbac.ts
new file mode 100644
index 000000000..5ebf1af74
--- /dev/null
+++ b/packages/shared/src/rbac.ts
@@ -0,0 +1,219 @@
+import { z } from "zod";
+import { isCanonicalUserId } from "./user-id";
+
+/** Stable identities for system-defined roles that cannot be replaced by custom roles. */
+export const BUILT_IN_ROLE_REGISTRY = {
+ owner: {
+ id: "role_builtin_owner",
+ key: "owner",
+ },
+ administrator: {
+ id: "role_builtin_administrator",
+ key: "administrator",
+ },
+ member: {
+ id: "role_builtin_member",
+ key: "member",
+ },
+ viewer: {
+ id: "role_builtin_viewer",
+ key: "viewer",
+ },
+} as const;
+
+/** A key identifying one of the workspace's system-defined roles. */
+export type BuiltInRoleKey = keyof typeof BUILT_IN_ROLE_REGISTRY;
+/** Built-in role keys in canonical registry order. */
+export const BUILT_IN_ROLE_KEYS = Object.keys(BUILT_IN_ROLE_REGISTRY) as BuiltInRoleKey[];
+
+/** Canonical permission identifiers accepted by the RBAC policy and persistence layers. */
+export const PERMISSION_IDS = [
+ "analytics.read",
+ "automations.create",
+ "automations.manage.any",
+ "automations.manage.own",
+ "automations.read",
+ "automations.trigger.any",
+ "automations.trigger.own",
+ "commit_signing.manage",
+ "environments.images.manage",
+ "environments.manage",
+ "environments.read",
+ "environments.secrets.manage",
+ "environments.settings.manage",
+ "environments.use",
+ "global_secrets.manage",
+ "image_builds.read",
+ "integrations.manage",
+ "integrations.read",
+ "mcp_servers.manage",
+ "mcp_servers.read",
+ "models.preferences.manage",
+ "provider_accounts.manage",
+ "provider_accounts.read",
+ "repositories.images.manage",
+ "repositories.read",
+ "repositories.secrets.manage",
+ "repositories.settings.manage",
+ "repositories.use",
+ "scm_settings.manage",
+ "sessions.collaborate",
+ "sessions.create",
+ "sessions.delete",
+ "sessions.lifecycle",
+ "sessions.read",
+ "sessions.sandbox_access",
+ "skill_profiles.manage_own",
+ "skills.manage",
+ "skills.read",
+ "workspace.members.manage",
+ "workspace.members.read",
+ "workspace.roles.read",
+ "workspace.transfer_ownership",
+] as const;
+
+/** A permission identifier recognized by the RBAC policy. */
+export type PermissionId = (typeof PERMISSION_IDS)[number];
+
+/** Maps ownership-sensitive capabilities to their workspace-wide and owner-only grants. */
+export const SCOPED_PERMISSION_PAIRS = {
+ "automations.manage": {
+ any: "automations.manage.any",
+ own: "automations.manage.own",
+ },
+ "automations.trigger": {
+ any: "automations.trigger.any",
+ own: "automations.trigger.own",
+ },
+} as const satisfies Record;
+
+/** A capability whose effective grant depends on resource ownership. */
+export type ScopedPermissionStem = keyof typeof SCOPED_PERMISSION_PAIRS;
+/** The resource ownership boundary granted for a scoped capability. */
+export type PermissionScope = "any" | "own";
+
+/** Resolves the strongest granted scope for a capability, preferring workspace-wide access. */
+export function resolveScopedPermission(
+ stem: ScopedPermissionStem,
+ permissions: readonly PermissionId[]
+): PermissionScope | null {
+ const pair = SCOPED_PERMISSION_PAIRS[stem];
+ if (permissions.includes(pair.any)) return "any";
+ if (permissions.includes(pair.own)) return "own";
+ return null;
+}
+
+const VIEWER_PERMISSIONS = new Set([
+ "analytics.read",
+ "automations.read",
+ "environments.read",
+ "image_builds.read",
+ "mcp_servers.read",
+ "repositories.read",
+ "sessions.read",
+ "skills.read",
+]);
+
+const MEMBER_PERMISSIONS = new Set([
+ ...VIEWER_PERMISSIONS,
+ "automations.create",
+ "automations.manage.own",
+ "automations.trigger.own",
+ "environments.use",
+ "provider_accounts.read",
+ "repositories.use",
+ "sessions.collaborate",
+ "sessions.create",
+ "sessions.delete",
+ "sessions.lifecycle",
+ "sessions.sandbox_access",
+ "skill_profiles.manage_own",
+]);
+
+/** Validates permission identifiers at API and storage boundaries. */
+export const permissionIdSchema = z.enum(PERMISSION_IDS);
+/** Validates keys for system-defined roles. */
+export const builtInRoleKeySchema = z.enum(BUILT_IN_ROLE_KEYS);
+
+/** Returns the canonical effective grants for a system-defined role. */
+export function permissionsForBuiltInRole(role: BuiltInRoleKey): PermissionId[] {
+ if (role === "owner") return [...PERMISSION_IDS];
+ if (role === "administrator") {
+ return PERMISSION_IDS.filter((permission) => permission !== "workspace.transfer_ownership");
+ }
+ const permissions = role === "member" ? MEMBER_PERMISSIONS : VIEWER_PERMISSIONS;
+ return PERMISSION_IDS.filter((permission) => permissions.has(permission));
+}
+
+/** Narrows untrusted permission text to the canonical permission registry. */
+export function isRegisteredPermission(value: string): value is PermissionId {
+ return (PERMISSION_IDS as readonly string[]).includes(value);
+}
+
+/** Reports whether a permission may be delegated through a custom role. */
+export function isCustomRolePermission(permission: PermissionId): boolean {
+ return permission !== "workspace.transfer_ownership";
+}
+
+/** Validates the role identity embedded in authorization responses. */
+export const roleReferenceSchema = z
+ .object({
+ id: z.string().min(1),
+ key: builtInRoleKeySchema.nullable(),
+ name: z.string().min(1),
+ })
+ .strict();
+
+/** Validates an administrative role view with effective grants and assignment count. */
+export const roleSummarySchema = roleReferenceSchema.extend({
+ description: z.string().nullable(),
+ permissions: z.array(permissionIdSchema),
+ assignmentCount: z.number().int().nonnegative(),
+});
+
+/** Validates a user's role, suspension state, and currently effective permissions. */
+export const effectiveAuthorizationSchema = z
+ .object({
+ userId: z.string().refine(isCanonicalUserId, "Invalid canonical user ID"),
+ suspendedAt: z.number().int().nonnegative().nullable(),
+ role: roleReferenceSchema,
+ permissions: z.array(permissionIdSchema),
+ })
+ .strict();
+
+/** Validates the member record exposed by workspace administration APIs. */
+export const workspaceMemberSchema = z
+ .object({
+ userId: z.string().refine(isCanonicalUserId, "Invalid canonical user ID"),
+ displayName: z.string().nullable(),
+ email: z.string().nullable(),
+ suspendedAt: z.number().int().nonnegative().nullable(),
+ role: roleReferenceSchema,
+ })
+ .strict();
+
+/** Validates the complete role-list response. */
+export const roleListResponseSchema = z.array(roleSummarySchema);
+/** Validates the complete workspace-member-list response. */
+export const workspaceMemberListResponseSchema = z.array(workspaceMemberSchema);
+
+/** Validates a request to atomically replace a member's assigned role. */
+export const replaceMemberRoleInputSchema = z
+ .object({
+ roleId: z.string().min(1),
+ })
+ .strict();
+
+/** Validates a request to suspend or reactivate a workspace member. */
+export const replaceMemberStatusInputSchema = z
+ .object({
+ suspended: z.boolean(),
+ })
+ .strict();
+
+/** Administrative role data with effective grants and current assignment count. */
+export type RoleSummary = z.infer;
+/** The authorization state used to make permission decisions for a user. */
+export type EffectiveAuthorization = z.infer;
+/** A workspace member and their current RBAC assignment state. */
+export type WorkspaceMember = z.infer;
diff --git a/scripts/bootstrap-workspace-owner.test.ts b/scripts/bootstrap-workspace-owner.test.ts
new file mode 100644
index 000000000..58eefd90b
--- /dev/null
+++ b/scripts/bootstrap-workspace-owner.test.ts
@@ -0,0 +1,310 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { DatabaseSync } from "node:sqlite";
+import { buildBootstrapSql, parseArgs } from "./bootstrap-workspace-owner.ts";
+
+const USER_ID = "11111111111111111111111111111111";
+const OTHER_USER_ID = "22222222222222222222222222222222";
+
+function createDatabase(): DatabaseSync {
+ const database = new DatabaseSync(":memory:");
+ database.exec(`
+ PRAGMA foreign_keys = ON;
+ CREATE TABLE users (
+ id TEXT PRIMARY KEY,
+ suspended_at INTEGER
+ );
+ CREATE TABLE roles (
+ id TEXT PRIMARY KEY,
+ key TEXT UNIQUE,
+ is_system INTEGER NOT NULL
+ );
+ CREATE TABLE user_role_assignments (
+ user_id TEXT PRIMARY KEY REFERENCES users(id),
+ role_id TEXT NOT NULL REFERENCES roles(id)
+ );
+ CREATE TABLE authorization_audit_events (
+ id TEXT PRIMARY KEY,
+ occurred_at INTEGER NOT NULL,
+ request_id TEXT NOT NULL,
+ principal_kind TEXT NOT NULL,
+ actor_user_id_snapshot TEXT,
+ actor_service_snapshot TEXT,
+ action TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ resource_id TEXT,
+ target_user_id_snapshot TEXT,
+ reason_code TEXT NOT NULL
+ );
+ INSERT INTO roles (id, key, is_system) VALUES
+ ('role_builtin_owner', 'owner', 1),
+ ('role_builtin_member', 'member', 1);
+ INSERT INTO users (id, suspended_at) VALUES ('${USER_ID}', NULL);
+ INSERT INTO user_role_assignments (user_id, role_id)
+ VALUES ('${USER_ID}', 'role_builtin_member');
+ `);
+ return database;
+}
+
+function sql(execute: boolean, auditId = "audit-id", now = 100): string {
+ return buildBootstrapSql({ userId: USER_ID, execute, auditId, now });
+}
+
+function preflight(database: DatabaseSync): Record {
+ return { ...database.prepare(sql(false, "unused", 0)).get() };
+}
+
+function execute(database: DatabaseSync, auditId: string, now: number): void {
+ database.exec(sql(true, auditId, now));
+}
+
+function insertPriorAudit(
+ database: DatabaseSync,
+ targetUserId = OTHER_USER_ID,
+ id = "audit-history"
+): void {
+ database
+ .prepare(
+ `INSERT INTO authorization_audit_events
+ (id, occurred_at, request_id, principal_kind,
+ actor_service_snapshot, action, resource_type, target_user_id_snapshot,
+ reason_code)
+ VALUES (?, 1, 'operator-cli:history', 'service',
+ 'operator-cli', 'workspace.owner_bootstrapped', 'workspace', ?,
+ 'operator_cli')`
+ )
+ .run(id, targetUserId);
+}
+
+describe("Owner bootstrap CLI arguments", () => {
+ it("defaults to a remote dry run and accepts explicit execution", () => {
+ assert.deepEqual(parseArgs(["--database", "open-inspect-prod", "--user", USER_ID]), {
+ database: "open-inspect-prod",
+ userId: USER_ID,
+ execute: false,
+ });
+ assert.deepEqual(
+ parseArgs(["--database", "open-inspect-dev", "--user", USER_ID, "--execute"]),
+ {
+ database: "open-inspect-dev",
+ userId: USER_ID,
+ execute: true,
+ }
+ );
+ });
+
+ it("rejects unknown, duplicate, missing, and non-canonical arguments", () => {
+ assert.throws(() => parseArgs(["--database", "db", "--user", USER_ID, "--force"]), /Unknown/);
+ assert.throws(
+ () => parseArgs(["--database", "db", "--database", "other", "--user", USER_ID]),
+ /Duplicate/
+ );
+ assert.throws(() => parseArgs(["--database", "--user", USER_ID]), /Missing value/);
+ assert.throws(
+ () => parseArgs(["--database", "db", "--user", "owner@example.com"]),
+ /canonical/
+ );
+ assert.throws(
+ () => parseArgs(["--database", "db", "--user", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaA"]),
+ /canonical/
+ );
+ });
+});
+
+describe("Owner bootstrap SQL", () => {
+ it("reports ready for an unsuspended target with one assignment and no Owner", () => {
+ const database = createDatabase();
+
+ assert.deepEqual(preflight(database), {
+ report: "preflight",
+ status: "ready",
+ detail: "selected user can be bootstrapped",
+ user_id: USER_ID,
+ suspended_at: null,
+ role_id: "role_builtin_member",
+ });
+ assert.equal(
+ database.prepare("SELECT role_id FROM user_role_assignments").get()!.role_id,
+ "role_builtin_member"
+ );
+ assert.equal(
+ database.prepare("SELECT COUNT(*) AS count FROM authorization_audit_events").get()!.count,
+ 0
+ );
+ });
+
+ it("assigns Owner and writes exactly one redacted successful service audit", () => {
+ const database = createDatabase();
+ execute(database, "audit-'success", 100);
+
+ assert.deepEqual(
+ {
+ ...database
+ .prepare(
+ `SELECT role_id
+ FROM user_role_assignments WHERE user_id = ?`
+ )
+ .get(USER_ID),
+ },
+ { role_id: "role_builtin_owner" }
+ );
+ assert.deepEqual(
+ {
+ ...database
+ .prepare(
+ `SELECT id, occurred_at, request_id, principal_kind, actor_user_id_snapshot,
+ actor_service_snapshot, action, resource_type, resource_id,
+ target_user_id_snapshot, reason_code
+ FROM authorization_audit_events`
+ )
+ .get(),
+ },
+ {
+ id: "audit-'success",
+ occurred_at: 100,
+ request_id: "operator-cli:audit-'success",
+ principal_kind: "service",
+ actor_user_id_snapshot: null,
+ actor_service_snapshot: "operator-cli",
+ action: "workspace.owner_bootstrapped",
+ resource_type: "workspace",
+ resource_id: null,
+ target_user_id_snapshot: USER_ID,
+ reason_code: "operator_cli",
+ }
+ );
+ });
+
+ it("is an idempotent no-op for the current unsuspended Owner", () => {
+ const database = createDatabase();
+ execute(database, "audit-first", 100);
+ execute(database, "audit-second", 200);
+
+ assert.deepEqual(preflight(database), {
+ report: "preflight",
+ status: "no-op",
+ detail: "selected user is already the current unsuspended Owner",
+ user_id: USER_ID,
+ suspended_at: null,
+ role_id: "role_builtin_owner",
+ });
+ assert.equal(
+ database.prepare("SELECT COUNT(*) AS count FROM authorization_audit_events").get()!.count,
+ 1
+ );
+ });
+
+ it("ignores prior bootstrap audit history when current Owner state is missing", () => {
+ const database = createDatabase();
+ insertPriorAudit(database);
+
+ assert.equal(preflight(database).status, "ready");
+ execute(database, "audit-current", 100);
+ assert.equal(
+ database.prepare("SELECT role_id FROM user_role_assignments").get()!.role_id,
+ "role_builtin_owner"
+ );
+ assert.equal(
+ database.prepare("SELECT COUNT(*) AS count FROM authorization_audit_events").get()!.count,
+ 2
+ );
+ });
+
+ it("cannot replay generated SQL after ownership conditions change", () => {
+ const database = createDatabase();
+ const generated = sql(true, "audit-replay", 100);
+ database.exec(generated);
+ database.exec(`
+ UPDATE user_role_assignments
+ SET role_id = 'role_builtin_member'
+ WHERE user_id = '${USER_ID}';
+ INSERT INTO users (id, suspended_at) VALUES ('${OTHER_USER_ID}', NULL);
+ INSERT INTO user_role_assignments (user_id, role_id)
+ VALUES ('${OTHER_USER_ID}', 'role_builtin_owner');
+ `);
+
+ database.exec(generated);
+
+ assert.equal(
+ database.prepare("SELECT role_id FROM user_role_assignments WHERE user_id = ?").get(USER_ID)!
+ .role_id,
+ "role_builtin_member"
+ );
+ });
+
+ it("refuses another unsuspended Owner without changing the selected user", () => {
+ const database = createDatabase();
+ database.exec(`
+ INSERT INTO users (id, suspended_at) VALUES ('${OTHER_USER_ID}', NULL);
+ INSERT INTO user_role_assignments (user_id, role_id)
+ VALUES ('${OTHER_USER_ID}', 'role_builtin_owner');
+ `);
+
+ assert.deepEqual(preflight(database), {
+ report: "preflight",
+ status: "refused",
+ detail: "another unsuspended Owner already exists",
+ user_id: USER_ID,
+ suspended_at: null,
+ role_id: "role_builtin_member",
+ });
+ execute(database, "audit-refused", 100);
+ assert.equal(
+ database.prepare("SELECT role_id FROM user_role_assignments WHERE user_id = ?").get(USER_ID)!
+ .role_id,
+ "role_builtin_member"
+ );
+ });
+
+ it("requires the RBAC schema and an unsuspended target with exactly one assignment", () => {
+ const missingSchema = new DatabaseSync(":memory:");
+ assert.throws(() => execute(missingSchema, "audit-missing-schema", 100), /no such table/);
+
+ const incompleteSchema = createDatabase();
+ incompleteSchema.exec("ALTER TABLE authorization_audit_events DROP COLUMN reason_code");
+ assert.deepEqual(preflight(incompleteSchema), {
+ report: "preflight",
+ status: "refused",
+ detail: "required RBAC schema is missing or incomplete",
+ user_id: USER_ID,
+ suspended_at: null,
+ role_id: "role_builtin_member",
+ });
+
+ const suspended = createDatabase();
+ suspended.exec(`UPDATE users SET suspended_at = 1 WHERE id = '${USER_ID}'`);
+ assert.equal(preflight(suspended).detail, "target user is suspended");
+ execute(suspended, "audit-suspended", 100);
+
+ const missingAssignment = createDatabase();
+ missingAssignment.exec(`DELETE FROM user_role_assignments WHERE user_id = '${USER_ID}'`);
+ assert.equal(
+ preflight(missingAssignment).detail,
+ "target must have exactly one role assignment"
+ );
+ execute(missingAssignment, "audit-unassigned", 100);
+ });
+
+ it("treats a current target Owner as a no-op without requiring audit history", () => {
+ const database = createDatabase();
+ database.exec(
+ `UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = '${USER_ID}'`
+ );
+ assert.equal(preflight(database).status, "no-op");
+ execute(database, "audit-no-op", 100);
+ assert.equal(
+ database.prepare("SELECT COUNT(*) AS count FROM authorization_audit_events").get()!.count,
+ 0
+ );
+ });
+
+ it("uses only current RBAC schema and the generated audit ID as execution provenance", () => {
+ const generated = sql(true, "audit-exact", 100);
+
+ assert.doesNotMatch(
+ generated,
+ /workspace_bootstrap|authorization_version|access_status|mutation_id|policy_id|operation_result|decision_outcome|metadata_json|actor_provider|assigned_by|assigned_at/
+ );
+ assert.match(generated, /SELECT 1 FROM authorization_audit_events WHERE id = 'audit-exact'/);
+ });
+});
diff --git a/scripts/bootstrap-workspace-owner.ts b/scripts/bootstrap-workspace-owner.ts
new file mode 100644
index 000000000..e0a1ac66b
--- /dev/null
+++ b/scripts/bootstrap-workspace-owner.ts
@@ -0,0 +1,284 @@
+/**
+ * Bootstrap the first workspace Owner by canonical user ID.
+ *
+ * Dry-run (remote D1 by default):
+ * npm run rbac:bootstrap-owner -- --database --user
+ *
+ * Execute after reviewing the preflight result:
+ * npm run rbac:bootstrap-owner -- --database --user --execute
+ *
+ * Wrangler uses the normal CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID
+ * environment variables or the credentials established by `wrangler login`.
+ */
+
+import { spawnSync } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+
+const CANONICAL_USER_ID = /^[0-9a-f]{32}$/;
+const OWNER_ROLE_ID = "role_builtin_owner";
+const VALUE_OPTIONS = new Set(["database", "user"]);
+const FLAG_OPTIONS = new Set(["execute"]);
+
+/** Validated command-line options for the Owner bootstrap operation. */
+export interface BootstrapCliOptions {
+ database: string;
+ userId: string;
+ execute: boolean;
+}
+
+/** Inputs used to build an Owner bootstrap preflight or execution script. */
+export interface BootstrapSqlOptions {
+ userId: string;
+ execute: boolean;
+ auditId: string;
+ now: number;
+}
+
+/** Parse and validate Owner bootstrap command-line arguments. */
+export function parseArgs(argv: string[]): BootstrapCliOptions {
+ const values = new Map();
+ const flags = new Set();
+
+ for (let index = 0; index < argv.length; index++) {
+ const argument = argv[index];
+ if (!argument.startsWith("--")) throw new Error(`Unexpected argument: ${argument}`);
+ const name = argument.slice(2);
+ if (FLAG_OPTIONS.has(name)) {
+ if (flags.has(name)) throw new Error(`Duplicate option: --${name}`);
+ flags.add(name);
+ continue;
+ }
+ if (!VALUE_OPTIONS.has(name)) throw new Error(`Unknown option: --${name}`);
+ if (values.has(name)) throw new Error(`Duplicate option: --${name}`);
+ const value = argv[++index];
+ if (value === undefined || value.startsWith("--")) {
+ throw new Error(`Missing value for --${name}`);
+ }
+ values.set(name, value);
+ }
+
+ const database = values.get("database");
+ if (!database?.trim()) throw new Error("--database is required");
+ const userId = values.get("user");
+ if (!userId) throw new Error("--user is required");
+ if (!CANONICAL_USER_ID.test(userId)) {
+ throw new Error("--user must be a canonical 32-character lowercase hexadecimal user ID");
+ }
+
+ return {
+ database: database.trim(),
+ userId,
+ execute: flags.has("execute"),
+ };
+}
+
+function sqlLiteral(value: string | number): string {
+ if (typeof value === "number") {
+ if (!Number.isSafeInteger(value)) throw new Error(`Unsafe SQL integer: ${value}`);
+ return String(value);
+ }
+ return `'${value.replaceAll("'", "''")}'`;
+}
+
+/** Build guarded SQL for an Owner bootstrap preflight or execution. */
+export function buildBootstrapSql(options: BootstrapSqlOptions): string {
+ const userId = sqlLiteral(options.userId);
+ const auditId = sqlLiteral(options.auditId);
+ const requestId = sqlLiteral(`operator-cli:${options.auditId}`);
+ const now = sqlLiteral(options.now);
+ const ownerRoleId = sqlLiteral(OWNER_ROLE_ID);
+ const targetIsOwner = `EXISTS (
+ SELECT 1 FROM user_role_assignments assignment
+ WHERE assignment.user_id = ${userId} AND assignment.role_id = ${ownerRoleId}
+ )`;
+ const anotherUnsuspendedOwner = `EXISTS (
+ SELECT 1 FROM users owner
+ JOIN user_role_assignments assignment ON assignment.user_id = owner.id
+ WHERE assignment.role_id = ${ownerRoleId}
+ AND owner.suspended_at IS NULL AND owner.id <> ${userId}
+ )`;
+ const schemaReady = `(SELECT COUNT(*) FROM pragma_table_info('users')
+ WHERE name IN ('id', 'suspended_at')) = 2
+ AND (SELECT COUNT(*) FROM pragma_table_info('roles')
+ WHERE name IN ('id', 'key', 'is_system')) = 3
+ AND (SELECT COUNT(*) FROM pragma_table_info('user_role_assignments')
+ WHERE name IN ('user_id', 'role_id')) = 2
+ AND (SELECT COUNT(*) FROM pragma_table_info('authorization_audit_events')
+ WHERE name IN (
+ 'id', 'occurred_at', 'request_id', 'principal_kind',
+ 'actor_user_id_snapshot', 'actor_service_snapshot', 'action', 'resource_type',
+ 'resource_id', 'target_user_id_snapshot', 'reason_code'
+ )) = 11`;
+ const commonPreconditions = `${schemaReady}
+ AND (SELECT COUNT(*) FROM users WHERE id = ${userId}) = 1
+ AND (SELECT COUNT(*) FROM user_role_assignments WHERE user_id = ${userId}) = 1
+ AND EXISTS (
+ SELECT 1 FROM users WHERE id = ${userId} AND suspended_at IS NULL
+ )
+ AND EXISTS (
+ SELECT 1 FROM roles
+ WHERE id = ${ownerRoleId} AND key = 'owner' AND is_system = 1
+ )`;
+ const ready = `${commonPreconditions}
+ AND NOT (${targetIsOwner})
+ AND NOT (${anotherUnsuspendedOwner})`;
+ const exactAudit = `EXISTS (
+ SELECT 1 FROM authorization_audit_events
+ WHERE id = ${auditId}
+ AND occurred_at = ${now}
+ AND request_id = ${requestId}
+ AND principal_kind = 'service'
+ AND actor_user_id_snapshot IS NULL
+ AND actor_service_snapshot = 'operator-cli'
+ AND action = 'workspace.owner_bootstrapped'
+ AND resource_type = 'workspace'
+ AND resource_id IS NULL
+ AND target_user_id_snapshot = ${userId}
+ AND reason_code = 'operator_cli'
+ )`;
+
+ const preflight = `SELECT 'preflight' AS report,
+ CASE
+ WHEN NOT (${schemaReady}) THEN 'refused'
+ WHEN (SELECT COUNT(*) FROM users WHERE id = ${userId}) <> 1 THEN 'refused'
+ WHEN (SELECT COUNT(*) FROM user_role_assignments WHERE user_id = ${userId}) <> 1 THEN 'refused'
+ WHEN NOT EXISTS (SELECT 1 FROM users WHERE id = ${userId} AND suspended_at IS NULL) THEN 'refused'
+ WHEN NOT EXISTS (
+ SELECT 1 FROM roles WHERE id = ${ownerRoleId} AND key = 'owner' AND is_system = 1
+ ) THEN 'refused'
+ WHEN ${anotherUnsuspendedOwner} THEN 'refused'
+ WHEN ${targetIsOwner} THEN 'no-op'
+ ELSE 'ready'
+ END AS status,
+ CASE
+ WHEN NOT (${schemaReady}) THEN 'required RBAC schema is missing or incomplete'
+ WHEN (SELECT COUNT(*) FROM users WHERE id = ${userId}) <> 1 THEN 'target user does not exist exactly once'
+ WHEN (SELECT COUNT(*) FROM user_role_assignments WHERE user_id = ${userId}) <> 1 THEN 'target must have exactly one role assignment'
+ WHEN NOT EXISTS (SELECT 1 FROM users WHERE id = ${userId} AND suspended_at IS NULL) THEN 'target user is suspended'
+ WHEN NOT EXISTS (
+ SELECT 1 FROM roles WHERE id = ${ownerRoleId} AND key = 'owner' AND is_system = 1
+ ) THEN 'built-in Owner role is missing or inconsistent'
+ WHEN ${anotherUnsuspendedOwner} THEN 'another unsuspended Owner already exists'
+ WHEN ${targetIsOwner} THEN 'selected user is already the current unsuspended Owner'
+ ELSE 'selected user can be bootstrapped'
+ END AS detail,
+ ${userId} AS user_id,
+ (SELECT suspended_at FROM users WHERE id = ${userId}) AS suspended_at,
+ (SELECT role_id FROM user_role_assignments WHERE user_id = ${userId}) AS role_id;`;
+
+ if (!options.execute) return `${preflight}\n`;
+
+ return `${preflight}
+
+INSERT INTO authorization_audit_events
+ (id, occurred_at, request_id, principal_kind,
+ actor_service_snapshot, action, resource_type,
+ target_user_id_snapshot, reason_code)
+SELECT ${auditId}, ${now}, ${requestId}, 'service',
+ 'operator-cli', 'workspace.owner_bootstrapped', 'workspace',
+ ${userId}, 'operator_cli'
+WHERE ${ready};
+
+UPDATE user_role_assignments
+SET role_id = ${ownerRoleId}
+WHERE user_id = ${userId} AND (${ready}) AND ${exactAudit};
+
+SELECT 'postcondition' AS report,
+ CASE
+ WHEN (${targetIsOwner}) AND (${exactAudit}) THEN 'executed'
+ WHEN ${targetIsOwner} THEN 'no-op'
+ ELSE 'refused'
+ END AS status,
+ u.id AS user_id,
+ u.suspended_at,
+ assignment.role_id,
+ EXISTS(SELECT 1 FROM authorization_audit_events WHERE id = ${auditId}) AS audit_written
+FROM users u
+JOIN user_role_assignments assignment ON assignment.user_id = u.id
+WHERE u.id = ${userId};
+`;
+}
+
+interface WranglerResult {
+ results?: Array>;
+ success?: boolean;
+}
+
+function reportRows(stdout: string): Array> {
+ const parsed = JSON.parse(stdout) as WranglerResult[];
+ const rows = parsed.flatMap((result) => result.results ?? []).filter((row) => row.report);
+ for (const row of rows) console.log(JSON.stringify(row));
+ return rows;
+}
+
+function runWrangler(database: string, operation: readonly string[]): string {
+ const child = spawnSync(
+ "npx",
+ ["wrangler", "d1", "execute", database, "--remote", ...operation, "--json"],
+ { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }
+ );
+ if (child.status !== 0) {
+ throw new Error(`Owner bootstrap refused or failed:\n${child.stderr || child.stdout}`);
+ }
+ return child.stdout;
+}
+
+function preflight(database: string, userId: string): string {
+ const sql = buildBootstrapSql({ userId, execute: false, auditId: "unused", now: 0 });
+ const rows = reportRows(runWrangler(database, ["--command", sql]));
+ const status = rows.find((row) => row.report === "preflight")?.status;
+ if (typeof status !== "string") throw new Error("Wrangler returned no Owner bootstrap preflight");
+ return status;
+}
+
+/** Run the remote Owner bootstrap workflow and verify its postcondition. */
+export async function run(options: BootstrapCliOptions): Promise {
+ console.error(`${options.execute ? "Executing" : "Dry-running"} Owner bootstrap on remote D1...`);
+ const status = preflight(options.database, options.userId);
+ if (status === "refused") throw new Error("Owner bootstrap preflight was refused");
+ if (status === "no-op") return;
+ if (!options.execute) {
+ console.error("Dry run only. Re-run with --execute after reviewing the preflight result.");
+ return;
+ }
+
+ const directory = await mkdtemp(join(tmpdir(), "open-inspect-owner-bootstrap-"));
+ const sqlPath = join(directory, "bootstrap.sql");
+ try {
+ await writeFile(
+ sqlPath,
+ buildBootstrapSql({
+ userId: options.userId,
+ execute: true,
+ auditId: crypto.randomUUID(),
+ now: Date.now(),
+ }),
+ { encoding: "utf8", mode: 0o600 }
+ );
+ runWrangler(options.database, ["--file", sqlPath]);
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+
+ if (preflight(options.database, options.userId) !== "no-op") {
+ throw new Error("Owner bootstrap postcondition verification failed");
+ }
+ console.error(
+ "Owner bootstrap command completed; verify /health reports ownerAssignment=present."
+ );
+}
+
+async function main(): Promise {
+ await run(parseArgs(process.argv.slice(2)));
+}
+
+const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null;
+if (invokedPath === import.meta.url) {
+ main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+ });
+}
diff --git a/terraform/d1/migrations/0071_rbac_foundation.sql b/terraform/d1/migrations/0071_rbac_foundation.sql
new file mode 100644
index 000000000..4297c3da2
--- /dev/null
+++ b/terraform/d1/migrations/0071_rbac_foundation.sql
@@ -0,0 +1,71 @@
+ALTER TABLE users ADD COLUMN suspended_at INTEGER;
+
+CREATE TABLE roles (
+ id TEXT PRIMARY KEY,
+ key TEXT UNIQUE,
+ name TEXT NOT NULL,
+ normalized_name TEXT NOT NULL UNIQUE,
+ description TEXT,
+ is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0, 1)),
+ CHECK (
+ (is_system = 1 AND key IN ('owner', 'administrator', 'member', 'viewer'))
+ OR (is_system = 0 AND key IS NULL)
+ )
+);
+
+-- Custom-role grants only; protected built-in grants are code-owned.
+CREATE TABLE role_permissions (
+ role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
+ permission_id TEXT NOT NULL,
+ PRIMARY KEY (role_id, permission_id)
+);
+
+CREATE TABLE user_role_assignments (
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE RESTRICT,
+ role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE RESTRICT
+);
+
+CREATE TABLE authorization_audit_events (
+ id TEXT PRIMARY KEY,
+ occurred_at INTEGER NOT NULL,
+ request_id TEXT NOT NULL,
+ principal_kind TEXT NOT NULL,
+ actor_user_id_snapshot TEXT,
+ actor_service_snapshot TEXT,
+ action TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ resource_id TEXT,
+ target_user_id_snapshot TEXT,
+ reason_code TEXT NOT NULL
+);
+
+CREATE INDEX idx_role_assignments_role ON user_role_assignments(role_id, user_id);
+
+INSERT INTO roles (
+ id, key, name, normalized_name, description, is_system
+) VALUES
+ ('role_builtin_owner', 'owner', 'Owner', 'owner', 'Full workspace control', 1),
+ ('role_builtin_administrator', 'administrator', 'Administrator', 'administrator', 'Operational administration without ownership transfer', 1),
+ ('role_builtin_member', 'member', 'Member', 'member', 'Session and automation collaboration', 1),
+ ('role_builtin_viewer', 'viewer', 'Viewer', 'viewer', 'Read-only workspace visibility', 1);
+
+INSERT INTO user_role_assignments (user_id, role_id)
+SELECT id, 'role_builtin_administrator' FROM users;
+
+CREATE TRIGGER assign_default_role_after_user_insert
+AFTER INSERT ON users
+BEGIN
+ INSERT INTO user_role_assignments (user_id, role_id)
+ VALUES (NEW.id, 'role_builtin_member')
+ ON CONFLICT(user_id) DO NOTHING;
+END;
+
+UPDATE automations
+SET user_id = (
+ SELECT identity.user_id
+ FROM user_identities identity
+ WHERE identity.provider = 'github'
+ AND identity.provider_user_id = automations.created_by
+)
+WHERE user_id IS NULL
+ AND created_by <> 'anonymous';
diff --git a/terraform/environments/production/outputs.tf b/terraform/environments/production/outputs.tf
index ab60312de..68cd16cd8 100644
--- a/terraform/environments/production/outputs.tf
+++ b/terraform/environments/production/outputs.tf
@@ -18,6 +18,11 @@ output "d1_database_id" {
value = cloudflare_d1_database.main.id
}
+output "d1_database_name" {
+ description = "The name of the D1 database used by operator CLI commands"
+ value = cloudflare_d1_database.main.name
+}
+
# Cloudflare Workers
output "control_plane_url" {
description = "Control plane worker URL"
From 46b620d5c522a0de5cae3f95fb3a55aa84abbe70 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Sun, 30 Aug 2026 21:47:36 -0700
Subject: [PATCH 2/9] feat: enforce workspace permissions at the HTTP boundary
---
.../src/auth/identity-enforcement.test.ts | 58 ++--
.../src/auth/identity-enforcement.ts | 20 +-
.../authorization/service-permissions.test.ts | 10 +
.../src/authorization/service-permissions.ts | 45 +++
.../src/router.analytics.test.ts | 21 +-
.../src/router.create-session.test.ts | 155 +++++++++-
.../control-plane/src/router.policy.test.ts | 175 +++++++++++
.../src/router.scm-credentials.test.ts | 17 +-
.../src/router.session-prompt.test.ts | 13 +-
.../src/router.spawn-child.test.ts | 70 ++++-
packages/control-plane/src/router.ts | 279 +++++++++++++++++-
.../control-plane/src/routes/analytics.ts | 5 +
packages/control-plane/src/routes/autofix.ts | 2 +
.../src/routes/automations.test.ts | 11 +-
.../control-plane/src/routes/automations.ts | 17 ++
.../control-plane/src/routes/browser-auth.ts | 5 +-
.../src/routes/commit-signing.ts | 7 +
.../src/routes/environment-secrets.ts | 5 +
.../control-plane/src/routes/environments.ts | 34 ++-
.../control-plane/src/routes/image-builds.ts | 10 +
.../src/routes/integration-settings.ts | 19 ++
.../src/routes/keyboard-shortcuts.ts | 39 +--
.../control-plane/src/routes/mcp-servers.ts | 6 +
.../src/routes/model-preferences.ts | 6 +
.../src/routes/model-provider-accounts.ts | 6 +
packages/control-plane/src/routes/rbac.ts | 114 +++++++
packages/control-plane/src/routes/repos.ts | 9 +
.../control-plane/src/routes/scm-settings.ts | 31 +-
packages/control-plane/src/routes/secrets.ts | 7 +
.../src/routes/session-attachments.ts | 3 +
.../src/routes/session-child-spawn.ts | 6 +
.../src/routes/session-children.ts | 6 +
.../src/routes/session-create.ts | 22 ++
.../control-plane/src/routes/session-diffs.ts | 6 +
.../src/routes/session-index.test.ts | 38 ++-
.../control-plane/src/routes/session-index.ts | 28 +-
.../src/routes/session-media-stream.ts | 4 +
.../src/routes/session-media-upload.ts | 2 +
.../src/routes/session-prompt.ts | 2 +
.../src/routes/session-pull-requests.ts | 2 +
.../src/routes/session-runtime-proxy.ts | 29 +-
.../src/routes/session-skills.ts | 7 +-
.../src/routes/session-ws-token.test.ts | 43 ++-
.../src/routes/session-ws-token.ts | 6 +-
packages/control-plane/src/routes/shared.ts | 150 +++++++++-
.../src/routes/sign-in-providers.ts | 2 +
packages/control-plane/src/routes/skills.ts | 53 +++-
.../src/webhooks/automation-event.ts | 3 +
.../src/webhooks/automation-webhook.ts | 2 +
packages/control-plane/src/webhooks/github.ts | 9 +-
packages/control-plane/src/webhooks/sentry.ts | 2 +
.../automations-slack-route.test.ts | 4 -
.../control-plane/test/integration/helpers.ts | 30 +-
.../test/integration/image-builds.test.ts | 18 +-
.../test/integration/service-auth.test.ts | 216 +++++++++++++-
.../linear-bot/src/webhook-handler.test.ts | 54 ++++
packages/linear-bot/src/webhook-handler.ts | 18 +-
packages/slack-bot/src/attachments.test.ts | 4 +-
packages/slack-bot/src/attachments.ts | 5 +-
.../slack-bot/src/sessions/prompt-delivery.ts | 2 +-
60 files changed, 1766 insertions(+), 206 deletions(-)
create mode 100644 packages/control-plane/src/authorization/service-permissions.test.ts
create mode 100644 packages/control-plane/src/authorization/service-permissions.ts
create mode 100644 packages/control-plane/src/routes/rbac.ts
diff --git a/packages/control-plane/src/auth/identity-enforcement.test.ts b/packages/control-plane/src/auth/identity-enforcement.test.ts
index 74a724738..e33bec55b 100644
--- a/packages/control-plane/src/auth/identity-enforcement.test.ts
+++ b/packages/control-plane/src/auth/identity-enforcement.test.ts
@@ -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> {
@@ -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",
@@ -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 });
});
});
@@ -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;
diff --git a/packages/control-plane/src/auth/identity-enforcement.ts b/packages/control-plane/src/auth/identity-enforcement.ts
index 7ae8620c1..29db52541 100644
--- a/packages/control-plane/src/auth/identity-enforcement.ts
+++ b/packages/control-plane/src/auth/identity-enforcement.ts
@@ -198,7 +198,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
@@ -219,7 +235,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),
diff --git a/packages/control-plane/src/authorization/service-permissions.test.ts b/packages/control-plane/src/authorization/service-permissions.test.ts
new file mode 100644
index 000000000..5b20f99fd
--- /dev/null
+++ b/packages/control-plane/src/authorization/service-permissions.test.ts
@@ -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);
+ });
+});
diff --git a/packages/control-plane/src/authorization/service-permissions.ts b/packages/control-plane/src/authorization/service-permissions.ts
new file mode 100644
index 000000000..1eb964df0
--- /dev/null
+++ b/packages/control-plane/src/authorization/service-permissions.ts
@@ -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 = {
+ 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);
+}
diff --git a/packages/control-plane/src/router.analytics.test.ts b/packages/control-plane/src/router.analytics.test.ts
index 997205157..4c8d5db66 100644
--- a/packages/control-plane/src/router.analytics.test.ts
+++ b/packages/control-plane/src/router.analytics.test.ts
@@ -27,7 +27,7 @@ describe("analytics router integration", () => {
vi.clearAllMocks();
});
- it("serves analytics routes even when the SCM provider is not github", async () => {
+ it("does not let an actorless service read analytics", async () => {
mockStore.getSummary.mockResolvedValue({
totalSessions: 1,
activeUsers: 1,
@@ -63,21 +63,8 @@ describe("analytics router integration", () => {
TEST_BACKGROUND_TASK_CONTEXT
);
- expect(response.status).toBe(200);
- await expect(response.json()).resolves.toEqual({
- totalSessions: 1,
- activeUsers: 1,
- totalCost: 0,
- avgCost: 0,
- totalPrs: 0,
- statusBreakdown: {
- created: 1,
- active: 0,
- completed: 0,
- failed: 0,
- archived: 0,
- cancelled: 0,
- },
- });
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({ code: "service_actor_required" });
+ expect(mockStore.getSummary).not.toHaveBeenCalled();
});
});
diff --git a/packages/control-plane/src/router.create-session.test.ts b/packages/control-plane/src/router.create-session.test.ts
index 0114453cd..908eff8bb 100644
--- a/packages/control-plane/src/router.create-session.test.ts
+++ b/packages/control-plane/src/router.create-session.test.ts
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { generateEncryptionKey } from "./auth/crypto";
-import type { Principal } from "./auth/principal";
import { SessionIndexStore } from "./db/session-index";
import { UserStore } from "./db/user-store";
import { handleRequest } from "./router";
@@ -15,6 +14,7 @@ import { SessionInternalPaths } from "./session/contracts";
import { resolveManagedSkills } from "./session/skill-resolution";
import { resolveSessionProviderAuth } from "./session/provider-account-resolution";
import { ProviderAccountSelectionPolicyError } from "./model-provider-accounts/selection-policy";
+import { resolveEnvironmentTarget, resolveSessionRepositories } from "./repos/resolve";
vi.mock("./db/session-index", () => ({
SessionIndexStore: vi.fn(),
@@ -45,10 +45,14 @@ vi.mock("./routes/shared", async (importOriginal) => {
};
});
-const USER_PRINCIPAL: Principal = {
- kind: "user",
- userId: "user-1",
-};
+vi.mock("./repos/resolve", async (importOriginal) => {
+ const actual = (await importOriginal()) as Record;
+ return {
+ ...actual,
+ resolveEnvironmentTarget: vi.fn(),
+ resolveSessionRepositories: vi.fn(),
+ };
+});
describe("handleCreateSession D1 ordering", () => {
beforeEach(() => {
@@ -68,6 +72,16 @@ describe("handleCreateSession D1 ordering", () => {
repoId: 12345,
defaultBranch: "main",
} as never);
+ vi.mocked(resolveEnvironmentTarget).mockResolvedValue([
+ { repoOwner: "acme", repoName: "environment-repo", baseBranch: "main" },
+ ]);
+ vi.mocked(resolveSessionRepositories).mockImplementation(async (_env, repositories) =>
+ repositories.map((repository, index) => ({
+ ...repository,
+ repoId: 12345 + index,
+ baseBranch: repository.baseBranch ?? "main",
+ }))
+ );
// Default identity fixture: the slack-bot's asserted actor resolves to an
// already-known canonical user with no linked GitHub identity.
vi.mocked(UserStore).mockImplementation(function () {
@@ -119,10 +133,17 @@ describe("handleCreateSession D1 ordering", () => {
);
}
- function createEnv(initFetch: ReturnType): Record {
+ function createEnv(
+ initFetch: ReturnType,
+ permissions = ["sessions.create", "repositories.use", "environments.use"]
+ ): Record {
const statement = {
bind: vi.fn(() => statement),
- first: vi.fn(async () => null),
+ first: vi
+ .fn()
+ .mockResolvedValueOnce({ suspended_at: null, assigned: 1 })
+ .mockResolvedValueOnce({ active: 1 })
+ .mockResolvedValue(null),
all: vi.fn(async () => ({ results: [] })),
run: vi.fn(async () => ({ meta: { changes: 0 } })),
};
@@ -134,7 +155,33 @@ describe("handleCreateSession D1 ordering", () => {
// the env must carry valid key material (the db stub answers "no rows").
TOKEN_ENCRYPTION_KEY: generateEncryptionKey(),
DB: {
- prepare: vi.fn(() => statement),
+ prepare: vi.fn((sql: string) => {
+ if (sql.includes("FROM users u") && sql.includes("user_role_assignments")) {
+ const authorizationStatement = {
+ bind: vi.fn(() => authorizationStatement),
+ first: vi.fn(async () => ({
+ user_id: "user-1",
+ suspended_at: null,
+ role_id: "role-1",
+ role_key: null,
+ role_name: "Test Role",
+ })),
+ all: vi.fn(async () => ({ results: [] })),
+ };
+ return authorizationStatement;
+ }
+ if (sql.includes("FROM role_permissions")) {
+ const permissionStatement = {
+ bind: vi.fn(() => permissionStatement),
+ first: vi.fn(async () => null),
+ all: vi.fn(async () => ({
+ results: permissions.map((permission_id) => ({ permission_id })),
+ })),
+ };
+ return permissionStatement;
+ }
+ return statement;
+ }),
batch: vi.fn(),
exec: vi.fn(),
dump: vi.fn(),
@@ -146,6 +193,76 @@ describe("handleCreateSession D1 ordering", () => {
};
}
+ it.each([
+ {
+ target: "environment",
+ body: { environmentId: "env_1" },
+ permissions: ["sessions.create", "environments.use"],
+ status: 201,
+ deniedPermission: null,
+ },
+ {
+ target: "environment",
+ body: { environmentId: "env_1" },
+ permissions: ["sessions.create", "repositories.use"],
+ status: 403,
+ deniedPermission: "environments.use",
+ },
+ {
+ target: "scalar repository",
+ body: { repoOwner: "acme", repoName: "widgets" },
+ permissions: ["sessions.create", "repositories.use"],
+ status: 201,
+ deniedPermission: null,
+ },
+ {
+ target: "scalar repository",
+ body: { repoOwner: "acme", repoName: "widgets" },
+ permissions: ["sessions.create", "environments.use"],
+ status: 403,
+ deniedPermission: "repositories.use",
+ },
+ {
+ target: "repository list",
+ body: { repositories: [{ repoOwner: "acme", repoName: "widgets" }] },
+ permissions: ["sessions.create", "repositories.use"],
+ status: 201,
+ deniedPermission: null,
+ },
+ {
+ target: "repository list",
+ body: { repositories: [{ repoOwner: "acme", repoName: "widgets" }] },
+ permissions: ["sessions.create", "environments.use"],
+ status: 403,
+ deniedPermission: "repositories.use",
+ },
+ ])(
+ "enforces the permission matrix for $target targets",
+ async ({ body, permissions, status, deniedPermission }) => {
+ const create = vi.fn().mockResolvedValue(undefined);
+ vi.mocked(SessionIndexStore).mockImplementation(function () {
+ return { create } as never;
+ });
+ const initFetch = vi.fn(async () => Response.json({ status: "created" }));
+
+ const response = await createSessionRequestWithBody(createEnv(initFetch, permissions), {
+ ...body,
+ title: "Permission matrix",
+ });
+
+ expect(response.status).toBe(status);
+ if (deniedPermission) {
+ await expect(response.json()).resolves.toMatchObject({
+ code: "permission_required",
+ permission: deniedPermission,
+ });
+ expect(create).not.toHaveBeenCalled();
+ } else {
+ expect(create).toHaveBeenCalledOnce();
+ }
+ }
+ );
+
it("does not initialize the SessionDO when D1 session index creation fails", async () => {
const create = vi.fn().mockRejectedValue(new Error("D1 unavailable"));
vi.mocked(SessionIndexStore).mockImplementation(function () {
@@ -452,9 +569,10 @@ describe("handleCreateSession D1 ordering", () => {
model: "anthropic/claude-haiku-4-5",
});
- expect(response.status).toBe(500);
+ expect(response.status).toBe(503);
await expect(response.json()).resolves.toEqual({
- error: "Failed to resolve session identity",
+ error: "Authorization unavailable",
+ code: "authorization_unavailable",
});
expect(create).not.toHaveBeenCalled();
expect(initFetch).not.toHaveBeenCalled();
@@ -511,7 +629,22 @@ describe("handleCreateSession D1 ordering", () => {
{
request_id: "test-request",
trace_id: "test-trace",
- principal: USER_PRINCIPAL,
+ principal: {
+ kind: "service",
+ service: "linear-bot",
+ actor: {
+ provider: "linear",
+ providerUserId: "linear-user-1",
+ canonicalUserId: "user-1",
+ participantUserId: "linear:linear-user-1",
+ },
+ },
+ authorization: {
+ userId: "user-1",
+ suspendedAt: null,
+ role: { id: "role-1", key: "member", name: "Member" },
+ permissions: ["sessions.create", "repositories.use", "environments.use"],
+ },
db: testEnv["DB"] as never,
executionCtx: TEST_BACKGROUND_TASK_CONTEXT,
metrics: {
diff --git a/packages/control-plane/src/router.policy.test.ts b/packages/control-plane/src/router.policy.test.ts
index 2765af085..56764d958 100644
--- a/packages/control-plane/src/router.policy.test.ts
+++ b/packages/control-plane/src/router.policy.test.ts
@@ -13,11 +13,165 @@ describe("route policy table", () => {
routes.every(
(route) =>
route.authentication &&
+ route.authorization &&
(route.supportedScmProviders === "all" || route.supportedScmProviders.length > 0)
)
).toBe(true);
});
+ it("has no duplicate method and pattern declarations", () => {
+ const identities = routes.map((route) => `${route.method}:${route.pattern}`);
+ expect(new Set(identities).size).toBe(identities.length);
+ });
+
+ it("declares authorization compatible with authentication", () => {
+ for (const route of routes) {
+ const authentication = route.authentication.kind;
+ const authorization = route.authorization;
+ if (authorization.kind === "none") {
+ expect(["public", "handler-authenticated", "web-service", "sandbox"]).toContain(
+ authentication
+ );
+ } else if (authorization.kind === "authenticated" || authorization.kind === "active-self") {
+ expect(authentication).toBe("user");
+ } else if (authorization.kind === "service") {
+ expect(authentication).toBe("user-or-service");
+ expect(authorization.services.length).toBeGreaterThan(0);
+ } else if (authorization.kind === "active-global") {
+ expect(["user", "user-or-service"]).toContain(authentication);
+ } else {
+ expect(["user", "user-or-service", "user-or-service-with-sandbox-fallback"]).toContain(
+ authentication
+ );
+ expect(authorization.allOf.length).toBeGreaterThan(0);
+ for (const requirement of authorization.allOf) {
+ if (requirement.kind === "automation") {
+ expect(route.pattern.source).toContain(`?<${requirement.automationIdParam}>`);
+ }
+ }
+ if (authorization.service.kind === "actor") {
+ for (const grant of authorization.service.actorlessGrants ?? []) {
+ for (const pathParam of Object.keys(grant.pathParams ?? {})) {
+ expect(route.pattern.source).toContain(`?<${pathParam}>`);
+ }
+ }
+ }
+ }
+ }
+ });
+
+ it.each([
+ ["GET", "/repos", [{ service: "slack-bot" }, { service: "linear-bot" }]],
+ ["GET", "/repos/acme/widgets/metadata", [{ service: "github-bot" }]],
+ ["GET", "/environments", [{ service: "slack-bot" }, { service: "linear-bot" }]],
+ ["GET", "/environments/env-1", [{ service: "github-bot" }]],
+ ["GET", "/integration-settings/slack", [{ service: "slack-bot", pathParams: { id: "slack" } }]],
+ [
+ "GET",
+ "/integration-settings/github/resolved/acme/widgets",
+ [
+ { service: "github-bot", pathParams: { id: "github" } },
+ { service: "linear-bot", pathParams: { id: "linear" } },
+ ],
+ ],
+ ["GET", "/integration-settings/slack/watched-channels", [{ service: "slack-bot" }]],
+ ["GET", "/model-preferences", [{ service: "slack-bot" }]],
+ ])("declares the exact actorless grants for %s %s", (method, path, expected) => {
+ const authorization = routeFor(method, path)?.authorization;
+ expect(["active-user", "active-global"]).toContain(authorization?.kind);
+ if (authorization?.kind === "active-user" || authorization?.kind === "active-global") {
+ expect(authorization.service.kind).toBe("actor");
+ if (authorization.service.kind === "actor") {
+ expect(authorization.service.actorlessGrants).toEqual(expected);
+ }
+ }
+ });
+
+ it("does not declare actorless grants on other routes", () => {
+ const expected = new Set([
+ routeFor("GET", "/repos"),
+ routeFor("GET", "/repos/acme/widgets/metadata"),
+ routeFor("GET", "/environments"),
+ routeFor("GET", "/environments/env-1"),
+ routeFor("GET", "/integration-settings/slack"),
+ routeFor("GET", "/integration-settings/github/resolved/acme/widgets"),
+ routeFor("GET", "/integration-settings/slack/watched-channels"),
+ routeFor("GET", "/model-preferences"),
+ routeFor("POST", "/sessions/session-1/stop"),
+ routeFor("GET", "/sessions/session-1/media/artifact-1"),
+ ]);
+ const granted = routes.filter(
+ (route) =>
+ (route.authorization.kind === "active-user" ||
+ route.authorization.kind === "active-global") &&
+ route.authorization.service.kind === "actor" &&
+ (route.authorization.service.actorlessGrants?.length ?? 0) > 0
+ );
+
+ expect(new Set(granted)).toEqual(expected);
+ });
+
+ it("keeps contextual route requirements explicit", () => {
+ expect(routeFor("GET", "/keyboard-shortcuts")?.authorization).toEqual({
+ kind: "active-self",
+ });
+ expect(routeFor("GET", "/model-preferences")?.authorization).toMatchObject({
+ kind: "active-global",
+ service: { kind: "actor", actorlessGrants: [{ service: "slack-bot" }] },
+ });
+ expect(routeFor("GET", "/sessions")?.authorization).toMatchObject({
+ kind: "active-user",
+ allOf: [{ kind: "permission", permission: "sessions.read" }],
+ service: { kind: "actor" },
+ });
+ expect(routeFor("GET", "/sessions/inbox")?.authorization).toMatchObject({
+ kind: "active-user",
+ allOf: [{ kind: "permission", permission: "sessions.read" }],
+ service: { kind: "deny" },
+ });
+ expect(routeFor("POST", "/sessions/session-1/stop")?.authorization).toMatchObject({
+ service: { kind: "actor", actorlessGrants: [{ service: "linear-bot" }] },
+ });
+ expect(routeFor("GET", "/sessions/session-1/media/artifact-1")?.authorization).toMatchObject({
+ service: { kind: "actor", actorlessGrants: [{ service: "slack-bot" }] },
+ });
+ expect(routeFor("POST", "/sessions/session-1/participants")?.authorization).toEqual({
+ kind: "active-user",
+ allOf: [{ kind: "permission", permission: "sessions.collaborate" }],
+ service: { kind: "actor" },
+ });
+ expect(routeFor("POST", "/sessions/parent/children")?.authorization).toMatchObject({
+ kind: "active-user",
+ allOf: [
+ { kind: "permission", permission: "sessions.create" },
+ { kind: "permission", permission: "sessions.collaborate" },
+ ],
+ });
+ expect(routeFor("GET", "/sessions/parent/children/child")?.authorization).toMatchObject({
+ kind: "active-user",
+ allOf: [{ kind: "permission", permission: "sessions.read" }],
+ });
+ expect(routeFor("POST", "/internal/github-event")?.authorization).toMatchObject({
+ kind: "service",
+ services: ["github-bot"],
+ });
+ });
+
+ it.each([
+ ["PUT", "/automations/automation-1", "manage"],
+ ["DELETE", "/automations/automation-1", "manage"],
+ ["POST", "/automations/automation-1/pause", "manage"],
+ ["POST", "/automations/automation-1/resume", "manage"],
+ ["POST", "/automations/automation-1/trigger", "trigger"],
+ ["POST", "/automations/automation-1/regenerate-key", "manage"],
+ ])("declares typed automation admission for %s %s", (method, path, operation) => {
+ expect(routeFor(method, path)?.authorization).toMatchObject({
+ kind: "active-user",
+ allOf: [{ kind: "automation", operation, automationIdParam: "id" }],
+ service: { kind: "deny" },
+ });
+ });
+
it.each([
["GET", "/health", "public"],
["POST", "/webhooks/sentry/automation-1", "handler-authenticated"],
@@ -61,6 +215,7 @@ describe("route policy table", () => {
if (route?.authentication.kind === "user-or-service-with-sandbox-fallback") {
expect(route.authentication.getSessionId(match)).toBe("session-1");
}
+ expect(route?.authorization.kind).toBe("active-user");
});
it.each([
@@ -199,6 +354,26 @@ describe("route policy dispatch ordering", () => {
});
});
+ it("keeps health live and private when the RBAC lookup fails", async () => {
+ const testEnv = env("github");
+ testEnv.DB.prepare = vi.fn(() => {
+ throw new Error("D1 unavailable");
+ });
+
+ const response = await handleRequest(
+ new Request("https://test.local/health"),
+ testEnv as never,
+ TEST_BACKGROUND_TASK_CONTEXT
+ );
+
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual({
+ status: "healthy",
+ service: "open-inspect-control-plane",
+ rbac: { ownerAssignment: "unknown" },
+ });
+ });
+
it("applies broker cache policy when sandbox authentication is unavailable", async () => {
const testEnv = env("github") as ReturnType & {
SESSION: {
diff --git a/packages/control-plane/src/router.scm-credentials.test.ts b/packages/control-plane/src/router.scm-credentials.test.ts
index 32c3e48fe..9862a58ea 100644
--- a/packages/control-plane/src/router.scm-credentials.test.ts
+++ b/packages/control-plane/src/router.scm-credentials.test.ts
@@ -128,7 +128,7 @@ describe("SCM credentials router provider gate", () => {
expect(new URL(fetch.mock.calls[1][0].url).pathname).toBe("/internal/scm-credentials");
});
- it("allows GitLab deployments to reach the tunnel URLs endpoint", async () => {
+ it("requires an actor for service access to tunnel URLs", async () => {
const { env, fetch } = createEnv();
const response = await handleRequest(
@@ -139,10 +139,9 @@ describe("SCM credentials router provider gate", () => {
TEST_BACKGROUND_TASK_CONTEXT
);
- expect(response.status).toBe(202);
- expect(fetch).toHaveBeenCalledOnce();
- const request = fetch.mock.calls[0][0];
- expect(new URL(request.url).pathname).toBe("/internal/tunnel-urls");
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({ code: "service_actor_required" });
+ expect(fetch).not.toHaveBeenCalled();
});
it("treats provider-neutral SCM settings routes as SCM-agnostic", () => {
@@ -218,7 +217,7 @@ describe("SCM credentials router provider gate", () => {
expect(new URL(fetch.mock.calls[0][0].url).pathname).toBe("/internal/verify-sandbox-token");
});
- it("continues blocking unrelated GitLab session routes", async () => {
+ it("rejects actorless services before unrelated GitLab session routes", async () => {
const { env, fetch } = createEnv();
const response = await handleRequest(
@@ -230,10 +229,8 @@ describe("SCM credentials router provider gate", () => {
TEST_BACKGROUND_TASK_CONTEXT
);
- expect(response.status).toBe(501);
- await expect(response.json()).resolves.toEqual({
- error: "SCM provider 'gitlab' is not implemented in this deployment.",
- });
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({ code: "service_actor_required" });
expect(fetch).not.toHaveBeenCalled();
});
diff --git a/packages/control-plane/src/router.session-prompt.test.ts b/packages/control-plane/src/router.session-prompt.test.ts
index 38074d47a..aed5096c7 100644
--- a/packages/control-plane/src/router.session-prompt.test.ts
+++ b/packages/control-plane/src/router.session-prompt.test.ts
@@ -58,8 +58,17 @@ function userPromptRequest(body: Record): Promise {
function createEnv(sessionFetch: ReturnType): Record {
const statement = {
bind: vi.fn(() => statement),
- first: vi.fn(async () => null),
- all: vi.fn(async () => ({ results: [] })),
+ first: vi.fn(async () => ({
+ user_id: "user-1",
+ suspended_at: null,
+ assigned: 1,
+ role_id: "role-administrator",
+ role_key: "administrator",
+ role_name: "Administrator",
+ })),
+ all: vi.fn(async () => ({
+ results: [{ permission_id: "sessions.collaborate" }],
+ })),
run: vi.fn(async () => ({ meta: { changes: 0 } })),
};
return {
diff --git a/packages/control-plane/src/router.spawn-child.test.ts b/packages/control-plane/src/router.spawn-child.test.ts
index 40d2ccfd5..666ea30d6 100644
--- a/packages/control-plane/src/router.spawn-child.test.ts
+++ b/packages/control-plane/src/router.spawn-child.test.ts
@@ -23,6 +23,12 @@ vi.mock("./db/model-preferences", () => ({
getEffectiveEnabledModels: vi.fn(),
}));
+vi.mock("./db/user-store", () => ({
+ UserStore: vi.fn().mockImplementation(function () {
+ return { getIdentity: async () => ({ userId: "canonical-user-123" }) };
+ }),
+}));
+
vi.mock("./session/integration-settings-resolution", () => integrationSettingsMocks);
describe("handleSpawnChild prompt enqueue handling", () => {
@@ -173,6 +179,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
method: "POST",
body: JSON.stringify(body),
service: "linear-bot",
+ actor: "linear:U1",
}),
env as never,
TEST_BACKGROUND_TASK_CONTEXT
@@ -198,7 +205,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
env: {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: (id: string) => (id === parentId ? parentStub : childStub),
@@ -340,7 +347,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: (id: string) => (id === parentId ? parentStub : childStub),
@@ -434,7 +441,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: (id: string) => (id === parentId ? parentStub : childStub),
@@ -482,7 +489,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: (id: string) => (id === parentId ? parentStub : childStub),
@@ -514,7 +521,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: () => parentStub,
@@ -525,6 +532,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
await signedServiceRequest(`https://test.local/sessions/${parentId}/children`, {
method: "POST",
service: "linear-bot",
+ actor: "linear:U1",
body: JSON.stringify({
title: "Child task",
prompt: "Do the thing",
@@ -554,7 +562,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: () => parentStub,
@@ -577,7 +585,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: vi.fn(),
@@ -588,6 +596,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
await signedServiceRequest(`https://test.local/sessions/${parentId}/children`, {
method: "POST",
service: "linear-bot",
+ actor: "linear:U1",
body: JSON.stringify({ title: "Child task" }),
}),
env as never,
@@ -612,7 +621,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: () => parentStub,
@@ -640,7 +649,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: () => parentStub,
@@ -666,7 +675,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: () => parentStub,
@@ -699,7 +708,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: () => parentStub,
@@ -740,7 +749,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: () => parentStub,
@@ -768,7 +777,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: () => parentStub,
@@ -779,6 +788,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
await signedServiceRequest(`https://test.local/sessions/${parentId}/children`, {
method: "POST",
service: "linear-bot",
+ actor: "linear:U1",
body: JSON.stringify({
title: "Child task",
prompt: "Do the thing",
@@ -809,7 +819,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: () => parentStub,
@@ -849,7 +859,7 @@ describe("handleSpawnChild prompt enqueue handling", () => {
const env = {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
- DB: {},
+ DB: authorizedDb(),
SESSION: {
idFromName: (name: string) => name,
get: (id: string) => (id === parentId ? parentStub : childStub),
@@ -866,3 +876,33 @@ describe("handleSpawnChild prompt enqueue handling", () => {
expect(store.updateStatus).toHaveBeenCalledWith(createdChildId, "failed");
});
});
+function authorizedDb() {
+ return {
+ prepare: vi.fn((sql: string) => {
+ const statement = {
+ bind: vi.fn(() => statement),
+ first: vi.fn(async () =>
+ sql.includes("FROM users u")
+ ? {
+ user_id: "canonical-user-123",
+ suspended_at: null,
+ role_id: "role-1",
+ role_key: "member",
+ role_name: "Member",
+ }
+ : null
+ ),
+ all: vi.fn(async () => ({
+ results: sql.includes("FROM role_permissions")
+ ? [
+ { permission_id: "sessions.create" },
+ { permission_id: "repositories.use" },
+ { permission_id: "sessions.collaborate" },
+ ]
+ : [],
+ })),
+ };
+ return statement;
+ }),
+ };
+}
diff --git a/packages/control-plane/src/router.ts b/packages/control-plane/src/router.ts
index ed694c9db..428ec9a81 100644
--- a/packages/control-plane/src/router.ts
+++ b/packages/control-plane/src/router.ts
@@ -15,15 +15,24 @@ 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 { SCOPED_PERMISSION_PAIRS, resolveScopedPermission } 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,
@@ -45,6 +54,7 @@ import { analyticsRoutes } from "./routes/analytics";
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";
@@ -295,6 +305,215 @@ export function enforceRoutePrincipal(
return null;
}
+async function enforceActiveUser(route: Route, ctx: RequestContext): 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 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;
+ try {
+ const authorization = await new AuthorizationService(ctx.db).getEffectiveAuthorization(userId);
+ ctx.authorization = authorization;
+ if (authorization.suspendedAt !== null) {
+ return json({ error: "Forbidden", code: "active_user_required" }, 403);
+ }
+ return null;
+ } catch (cause) {
+ if (cause instanceof AuthorizationError) {
+ return json({ error: "Forbidden", code: cause.code }, cause.status);
+ }
+ return 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
+): Response | null {
+ const principal = ctx.principal;
+ if (principal?.kind !== "service") return null;
+ if (route.authentication.kind === "web-service" && principal.service === "web") return null;
+
+ const authorization = route.authorization;
+ if (authorization.kind === "service") {
+ if (!authorization.services.some((service) => service === principal.service)) {
+ return json({ error: "Forbidden", code: "service_capability_required" }, 403);
+ }
+ if (authorization.actor === "required" && !principal.actor) {
+ return json({ error: "Forbidden", code: "service_actor_required" }, 403);
+ }
+ return null;
+ }
+ if (
+ (authorization.kind !== "active-user" && authorization.kind !== "active-global") ||
+ authorization.service.kind === "deny"
+ ) {
+ return json({ error: "Forbidden", code: "service_capability_required" }, 403);
+ }
+ if (principal.actor) return null;
+ const granted = authorization.service.actorlessGrants?.some((grant) =>
+ actorlessGrantMatches(grant, principal.service, match)
+ );
+ return granted ? null : json({ error: "Forbidden", code: "service_actor_required" }, 403);
+}
+
+async function enforcePermissionRequirement(
+ requirement: Extract,
+ ctx: RequestContext
+): Promise {
+ const userId = authorizationUserId(ctx);
+ if (!userId) return null;
+ if (
+ ctx.principal?.kind === "service" &&
+ !serviceAllowsPermission(ctx.principal.service, requirement.permission)
+ ) {
+ return json({ error: "Forbidden", code: "service_capability_required" }, 403);
+ }
+ if (ctx.authorization?.permissions.includes(requirement.permission)) return null;
+ return json(
+ { error: "Forbidden", code: "permission_required", permission: requirement.permission },
+ 403
+ );
+}
+
+async function enforceScopedPermissionRequirement(
+ requirement: Extract,
+ ctx: RequestContext
+): Promise {
+ const userId = authorizationUserId(ctx);
+ if (!userId) return null;
+ const pair = SCOPED_PERMISSION_PAIRS[requirement.stem];
+ if (
+ ctx.principal?.kind === "service" &&
+ !serviceAllowsPermission(ctx.principal.service, pair.own)
+ ) {
+ return json({ error: "Forbidden", code: "service_capability_required" }, 403);
+ }
+ if (
+ ctx.authorization &&
+ resolveScopedPermission(requirement.stem, ctx.authorization.permissions)
+ ) {
+ return null;
+ }
+ return json({ error: "Forbidden", code: "permission_required", permission: pair.own }, 403);
+}
+
+async function enforceAutomationRequirement(
+ requirement: Extract,
+ match: RegExpMatchArray,
+ ctx: RequestContext
+): Promise {
+ if (ctx.principal?.kind !== "user") return null;
+ const encodedAutomationId = match.groups?.[requirement.automationIdParam];
+ if (!encodedAutomationId) return json({ error: "Invalid automation route" }, 400);
+ let automationId: string;
+ try {
+ automationId = decodeURIComponent(encodedAutomationId);
+ } catch {
+ return json({ error: "Invalid automation route" }, 400);
+ }
+
+ try {
+ const authorization = ctx.authorization;
+ if (!authorization) throw new Error("Missing request authorization");
+ const automation = await new AutomationStore(ctx.db).getById(automationId);
+ if (!automation) return error("Automation not found", 404);
+
+ const permissionStem = `automations.${requirement.operation}` as const;
+ const permissionScope = resolveScopedPermission(permissionStem, authorization.permissions);
+ const ownPermission = SCOPED_PERMISSION_PAIRS[permissionStem].own;
+ if (
+ !permissionScope ||
+ (permissionScope === "own" && automation.user_id !== ctx.principal.userId)
+ ) {
+ return json(
+ { error: "Forbidden", code: "permission_required", permission: ownPermission },
+ 403
+ );
+ }
+
+ ctx.automationAdmission = { automation };
+ return null;
+ } catch {
+ return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503);
+ }
+}
+
+async function enforceRouteAuthorization(
+ route: Route,
+ match: RegExpMatchArray,
+ ctx: RequestContext
+): Promise {
+ if (route.authorization.kind !== "active-user") return null;
+ for (const requirement of route.authorization.allOf) {
+ let authorizationError: Response | null;
+ switch (requirement.kind) {
+ case "permission":
+ authorizationError = await enforcePermissionRequirement(requirement, ctx);
+ break;
+ case "scoped-permission":
+ authorizationError = await enforceScopedPermissionRequirement(requirement, ctx);
+ break;
+ case "automation":
+ authorizationError = await enforceAutomationRequirement(requirement, match, ctx);
+ break;
+ }
+ if (authorizationError) return authorizationError;
+ }
+ return null;
+}
+
/**
* Routes definition.
*/
@@ -305,7 +524,29 @@ export const routes: Route[] = [
supportedScmProviders: "all",
method: "GET",
pattern: parsePattern("/health"),
- handler: async () => json({ status: "healthy", service: "open-inspect-control-plane" }),
+ authorization: NO_AUTHORIZATION,
+ handler: async (_request, _env, _match, ctx) => {
+ let ownerAssignment: "present" | "missing" | "unknown";
+ try {
+ const owner = await ctx.db
+ .prepare(
+ `SELECT 1 AS complete FROM users u
+ JOIN user_role_assignments ura ON ura.user_id = u.id
+ JOIN roles r ON r.id = ura.role_id
+ WHERE r.key = 'owner' AND u.suspended_at IS NULL
+ LIMIT 1`
+ )
+ .first();
+ ownerAssignment = owner ? "present" : "missing";
+ } catch {
+ ownerAssignment = "unknown";
+ }
+ return json({
+ status: "healthy",
+ service: "open-inspect-control-plane",
+ rbac: { ownerAssignment },
+ });
+ },
},
...browserAuthRoutes,
@@ -317,6 +558,7 @@ export const routes: Route[] = [
defineRoute(GITHUB_SANDBOX_FALLBACK_ROUTE, {
method: "POST",
pattern: parsePattern("/sessions/:id/slack-notify"),
+ authorization: requirePermission("sessions.collaborate"),
handler: handleSlackNotify,
}),
@@ -366,6 +608,9 @@ export const routes: Route[] = [
// Personal keyboard shortcuts
...keyboardShortcutRoutes,
+ // Workspace roles, members, and current-user authorization
+ ...rbacRoutes,
+
// Webhooks (public routes — auth handled per-route)
...webhookRoutes,
];
@@ -495,6 +740,38 @@ export async function handleRequest(
}
}
+ const serviceAccessError = enforceServiceRouteAuthorization(
+ matchedRoute.route,
+ matchedRoute.match,
+ ctx
+ );
+ if (serviceAccessError) {
+ logRequest(serviceAccessError, ctx, method, path, startTime);
+ return withCorsAndTraceHeaders(
+ withRouteCachePolicy(serviceAccessError, matchedRoute.route),
+ ctx
+ );
+ }
+
+ const userAccessError = await enforceActiveUser(matchedRoute.route, ctx);
+ if (userAccessError) {
+ logRequest(userAccessError, ctx, method, path, startTime);
+ return withCorsAndTraceHeaders(withRouteCachePolicy(userAccessError, matchedRoute.route), ctx);
+ }
+
+ const authorizationError = await enforceRouteAuthorization(
+ matchedRoute.route,
+ matchedRoute.match,
+ ctx
+ );
+ if (authorizationError) {
+ logRequest(authorizationError, ctx, method, path, startTime);
+ return withCorsAndTraceHeaders(
+ withRouteCachePolicy(authorizationError, matchedRoute.route),
+ ctx
+ );
+ }
+
const providerCheck = enforceImplementedScmProvider(matchedRoute.route, path, env, ctx);
if (providerCheck) {
return withRouteCachePolicy(providerCheck, matchedRoute.route);
diff --git a/packages/control-plane/src/routes/analytics.ts b/packages/control-plane/src/routes/analytics.ts
index 5bbef8120..53e17b20b 100644
--- a/packages/control-plane/src/routes/analytics.ts
+++ b/packages/control-plane/src/routes/analytics.ts
@@ -18,6 +18,7 @@ import {
error,
json,
parsePattern,
+ requirePermission,
} from "./shared";
function parseDaysParam(value: string | null): AnalyticsDays | null {
@@ -124,21 +125,25 @@ export const analyticsRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_USER_OR_SERVIC
{
method: "GET",
pattern: parsePattern("/analytics/summary"),
+ authorization: requirePermission("analytics.read"),
handler: handleSummary,
},
{
method: "GET",
pattern: parsePattern("/analytics/timeseries"),
+ authorization: requirePermission("analytics.read"),
handler: handleTimeseries,
},
{
method: "GET",
pattern: parsePattern("/analytics/breakdown"),
+ authorization: requirePermission("analytics.read"),
handler: handleBreakdown,
},
{
method: "GET",
pattern: parsePattern("/analytics/pull-requests"),
+ authorization: requirePermission("analytics.read"),
handler: handlePullRequests,
},
]);
diff --git a/packages/control-plane/src/routes/autofix.ts b/packages/control-plane/src/routes/autofix.ts
index 723dbb295..f132d243d 100644
--- a/packages/control-plane/src/routes/autofix.ts
+++ b/packages/control-plane/src/routes/autofix.ts
@@ -3,6 +3,7 @@ import {
defineRoutes,
error,
json,
+ NO_AUTHORIZATION,
parsePattern,
SCM_AGNOSTIC_WEB_SERVICE_ROUTE,
type Route,
@@ -35,6 +36,7 @@ export const autofixRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_WEB_SERVICE_ROUT
{
method: "GET",
pattern: parsePattern("/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 c51752577..769ca4953 100644
--- a/packages/control-plane/src/routes/automations.test.ts
+++ b/packages/control-plane/src/routes/automations.test.ts
@@ -179,11 +179,20 @@ const SLACK_BOT_PRINCIPAL: Principal = {
};
function createCtx(principal: Principal = USER_PRINCIPAL): RequestContext {
+ const statement = {
+ bind: vi.fn(),
+ first: vi.fn(async () => ({ active: 1 })),
+ };
+ statement.bind.mockReturnValue(statement);
+
return {
trace_id: "trace-1",
request_id: "req-1",
principal,
- db: { batch: mockBatch } as unknown as SqlDatabase,
+ db: {
+ batch: mockBatch,
+ prepare: vi.fn(() => statement),
+ } as unknown as SqlDatabase,
executionCtx: TEST_BACKGROUND_TASK_CONTEXT,
metrics: {
d1Queries: [],
diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts
index 624232123..fc638ed7f 100644
--- a/packages/control-plane/src/routes/automations.ts
+++ b/packages/control-plane/src/routes/automations.ts
@@ -61,6 +61,8 @@ import {
error,
parseJsonBody,
resolveRepoOrError,
+ requireAutomation,
+ requirePermission,
} from "./shared";
import type { Env } from "../types";
import type { SqlDatabase, SqlStatement } from "../db/sql-database";
@@ -1354,66 +1356,81 @@ export const automationRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROU
{
method: "GET",
pattern: parsePattern("/integration-settings/slack/watched-channels"),
+ authorization: requirePermission("automations.read", {
+ actorlessGrants: [{ service: "slack-bot" }],
+ }),
handler: handleGetWatchedSlackChannels,
},
{
method: "GET",
pattern: parsePattern("/integration-settings/slack/channels"),
+ authorization: requirePermission("automations.read"),
handler: handleGetSlackChannels,
},
{
method: "GET",
pattern: parsePattern("/automations"),
+ authorization: requirePermission("automations.read"),
handler: handleListAutomations,
},
{
method: "POST",
pattern: parsePattern("/automations"),
+ authorization: requirePermission("automations.create"),
handler: handleCreateAutomation,
},
{
method: "GET",
pattern: parsePattern("/automations/:id"),
+ authorization: requirePermission("automations.read"),
handler: handleGetAutomation,
},
{
method: "PUT",
pattern: parsePattern("/automations/:id"),
+ authorization: requireAutomation("manage"),
handler: handleUpdateAutomation,
},
{
method: "DELETE",
pattern: parsePattern("/automations/:id"),
+ authorization: requireAutomation("manage"),
handler: handleDeleteAutomation,
},
{
method: "POST",
pattern: parsePattern("/automations/:id/pause"),
+ authorization: requireAutomation("manage"),
handler: handlePauseAutomation,
},
{
method: "POST",
pattern: parsePattern("/automations/:id/resume"),
+ authorization: requireAutomation("manage"),
handler: handleResumeAutomation,
},
{
method: "POST",
pattern: parsePattern("/automations/:id/trigger"),
+ authorization: requireAutomation("trigger"),
handler: handleTriggerAutomation,
},
{
method: "GET",
pattern: parsePattern("/automations/:id/invocations"),
+ authorization: requirePermission("automations.read"),
handler: handleListInvocations,
},
{
method: "GET",
pattern: parsePattern("/automations/:id/runs/:runId"),
+ authorization: requirePermission("automations.read"),
handler: handleGetRun,
},
{
method: "POST",
pattern: parsePattern("/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 7bbc71546..395fe0eb8 100644
--- a/packages/control-plane/src/routes/browser-auth.ts
+++ b/packages/control-plane/src/routes/browser-auth.ts
@@ -4,6 +4,7 @@ import { createLogger } from "../logger";
import {
defineRoutes,
error,
+ NO_AUTHORIZATION,
parsePattern,
SCM_AGNOSTIC_WEB_SERVICE_ROUTE,
type Route,
@@ -54,7 +55,8 @@ const handleBrowserAuth: Route["handler"] = async (request, _env, _match, ctx) =
if (!ctx.getUserAuth) {
throw new UserAuthConfigurationError("User authentication runtime is unavailable");
}
- const response = await forwardBrowserAuthRequest(ctx.getUserAuth(), request);
+ const auth = ctx.getUserAuth();
+ const response = await forwardBrowserAuthRequest(auth, request);
const headers = copyBrowserAuthResponseHeaders(response.headers);
headers.set("Cache-Control", "no-store");
headers.set("Referrer-Policy", "no-referrer");
@@ -86,6 +88,7 @@ export const browserAuthRoutes: Route[] = defineRoutes(
BROWSER_AUTH_PROXY_ROUTES.map(([method, path]) => ({
method,
pattern: parsePattern(path),
+ authorization: NO_AUTHORIZATION,
handler: handleBrowserAuth,
}))
);
diff --git a/packages/control-plane/src/routes/commit-signing.ts b/packages/control-plane/src/routes/commit-signing.ts
index a9e851176..9286faa8a 100644
--- a/packages/control-plane/src/routes/commit-signing.ts
+++ b/packages/control-plane/src/routes/commit-signing.ts
@@ -19,6 +19,8 @@ import {
defineRoute,
GITHUB_USER_OR_SERVICE_ROUTE,
SCM_AGNOSTIC_SANDBOX_ROUTE,
+ NO_AUTHORIZATION,
+ requirePermission,
} from "./shared";
const MAX_SIGNING_PAYLOAD_BYTES = 1024 * 1024;
@@ -215,26 +217,31 @@ export const commitSigningRoutes: Route[] = [
defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, {
method: "GET",
pattern: parsePattern("/commit-signing"),
+ authorization: requirePermission("integrations.read"),
handler: handleGetCommitSigning,
}),
defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, {
method: "PUT",
pattern: parsePattern("/commit-signing"),
+ authorization: requirePermission("commit_signing.manage"),
handler: handlePutCommitSigning,
}),
defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, {
method: "DELETE",
pattern: parsePattern("/commit-signing"),
+ authorization: requirePermission("commit_signing.manage"),
handler: handleDeleteCommitSigning,
}),
defineRoute(SCM_AGNOSTIC_SANDBOX_ROUTE, {
method: "GET",
pattern: parsePattern("/sessions/:id/commit-signing"),
+ authorization: NO_AUTHORIZATION,
handler: handleGetSandboxCommitSigning,
}),
defineRoute(SCM_AGNOSTIC_SANDBOX_ROUTE, {
method: "POST",
pattern: parsePattern("/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 790c073df..460d56505 100644
--- a/packages/control-plane/src/routes/environment-secrets.ts
+++ b/packages/control-plane/src/routes/environment-secrets.ts
@@ -23,6 +23,7 @@ import {
error,
parseJsonBody,
resolveRepoOrError,
+ requirePermission,
} from "./shared";
import {
environmentSecretsImportBodySchema,
@@ -304,21 +305,25 @@ export const environmentSecretsRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SER
{
method: "GET",
pattern: parsePattern("/environments/:id/secrets"),
+ authorization: requirePermission("environments.secrets.manage"),
handler: handleListEnvironmentSecrets,
},
{
method: "PUT",
pattern: parsePattern("/environments/:id/secrets"),
+ authorization: requirePermission("environments.secrets.manage"),
handler: handleSetEnvironmentSecrets,
},
{
method: "POST",
pattern: parsePattern("/environments/:id/secrets/import"),
+ authorization: requirePermission("environments.secrets.manage"),
handler: handleImportEnvironmentSecrets,
},
{
method: "DELETE",
pattern: parsePattern("/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 80fc8942e..17201e3e8 100644
--- a/packages/control-plane/src/routes/environments.ts
+++ b/packages/control-plane/src/routes/environments.ts
@@ -30,6 +30,7 @@ import {
error,
parseJsonBody,
resolveRepoOrError,
+ requirePermission,
} from "./shared";
import type { Env } from "../types";
@@ -263,13 +264,38 @@ async function handleDeleteEnvironment(
}
export const environmentRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [
- { method: "GET", pattern: parsePattern("/environments"), handler: handleListEnvironments },
- { method: "POST", pattern: parsePattern("/environments"), handler: handleCreateEnvironment },
- { method: "GET", pattern: parsePattern("/environments/:id"), handler: handleGetEnvironment },
- { method: "PUT", pattern: parsePattern("/environments/:id"), handler: handleUpdateEnvironment },
+ {
+ method: "GET",
+ pattern: parsePattern("/environments"),
+ authorization: requirePermission("environments.read", {
+ actorlessGrants: [{ service: "slack-bot" }, { service: "linear-bot" }],
+ }),
+ handler: handleListEnvironments,
+ },
+ {
+ method: "POST",
+ pattern: parsePattern("/environments"),
+ authorization: requirePermission("environments.manage"),
+ handler: handleCreateEnvironment,
+ },
+ {
+ method: "GET",
+ pattern: parsePattern("/environments/:id"),
+ authorization: requirePermission("environments.read", {
+ actorlessGrants: [{ service: "github-bot" }],
+ }),
+ handler: handleGetEnvironment,
+ },
+ {
+ method: "PUT",
+ pattern: parsePattern("/environments/:id"),
+ authorization: requirePermission("environments.manage"),
+ handler: handleUpdateEnvironment,
+ },
{
method: "DELETE",
pattern: parsePattern("/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 6610925c7..d199bf7a1 100644
--- a/packages/control-plane/src/routes/image-builds.ts
+++ b/packages/control-plane/src/routes/image-builds.ts
@@ -49,6 +49,8 @@ import {
json,
parseJsonBody,
parsePattern,
+ NO_AUTHORIZATION,
+ requirePermission,
} from "./shared";
const logger = createLogger("router:image-builds");
@@ -494,41 +496,49 @@ export const imageBuildRoutes: Route[] = [
defineRoute(SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE, {
method: "POST",
pattern: parsePattern("/image-builds/build-complete"),
+ authorization: NO_AUTHORIZATION,
handler: handleBuildComplete,
}),
defineRoute(SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE, {
method: "POST",
pattern: parsePattern("/image-builds/build-failed"),
+ authorization: NO_AUTHORIZATION,
handler: handleBuildFailed,
}),
defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, {
method: "POST",
pattern: parsePattern("/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"),
+ authorization: requirePermission("repositories.images.manage"),
handler: handleTriggerRepoBuild,
}),
defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, {
method: "PUT",
pattern: parsePattern("/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"),
+ authorization: requirePermission("image_builds.read"),
handler: handleGetStatus,
}),
defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, {
method: "GET",
pattern: parsePattern("/image-builds/enabled"),
+ authorization: requirePermission("image_builds.read"),
handler: handleGetEnabledUnits,
}),
defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, {
method: "GET",
pattern: parsePattern("/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 1d1ca7548..bc2e81e89 100644
--- a/packages/control-plane/src/routes/integration-settings.ts
+++ b/packages/control-plane/src/routes/integration-settings.ts
@@ -34,6 +34,7 @@ import {
error,
parseJsonBody,
extractRepoParams,
+ requirePermission,
} from "./shared";
const logger = createLogger("router:integration-settings");
@@ -492,37 +493,46 @@ export const integrationSettingsRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SE
{
method: "GET",
pattern: parsePattern("/integration-settings/:id"),
+ authorization: requirePermission("integrations.read", {
+ actorlessGrants: [{ service: "slack-bot", pathParams: { id: "slack" } }],
+ }),
handler: handleGetIntegrationSettings,
},
{
method: "PUT",
pattern: parsePattern("/integration-settings/:id"),
+ authorization: requirePermission("integrations.manage"),
handler: handleSetIntegrationSettings,
},
{
method: "DELETE",
pattern: parsePattern("/integration-settings/:id"),
+ authorization: requirePermission("integrations.manage"),
handler: handleDeleteIntegrationSettings,
},
// Integration settings — per-repo
{
method: "GET",
pattern: parsePattern("/integration-settings/:id/repos"),
+ authorization: requirePermission("integrations.read"),
handler: handleListRepoSettings,
},
{
method: "GET",
pattern: parsePattern("/integration-settings/:id/repos/:owner/:name"),
+ authorization: requirePermission("integrations.read"),
handler: handleGetRepoSettings,
},
{
method: "PUT",
pattern: parsePattern("/integration-settings/:id/repos/:owner/:name"),
+ authorization: requirePermission("repositories.settings.manage"),
handler: handleSetRepoSettings,
},
{
method: "DELETE",
pattern: parsePattern("/integration-settings/:id/repos/:owner/:name"),
+ authorization: requirePermission("repositories.settings.manage"),
handler: handleDeleteRepoSettings,
},
// Integration settings — per-environment (design §13.5; sandbox and
@@ -530,22 +540,31 @@ export const integrationSettingsRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SE
{
method: "GET",
pattern: parsePattern("/integration-settings/:id/environments/:environmentId"),
+ authorization: requirePermission("integrations.read"),
handler: handleGetEnvironmentSettings,
},
{
method: "PUT",
pattern: parsePattern("/integration-settings/:id/environments/:environmentId"),
+ authorization: requirePermission("environments.settings.manage"),
handler: handleSetEnvironmentSettings,
},
{
method: "DELETE",
pattern: parsePattern("/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"),
+ authorization: requirePermission("integrations.read", {
+ actorlessGrants: [
+ { service: "github-bot", pathParams: { id: "github" } },
+ { service: "linear-bot", pathParams: { id: "linear" } },
+ ],
+ }),
handler: handleGetResolvedConfig,
},
]);
diff --git a/packages/control-plane/src/routes/keyboard-shortcuts.ts b/packages/control-plane/src/routes/keyboard-shortcuts.ts
index 14ab95053..9bf4a22b6 100644
--- a/packages/control-plane/src/routes/keyboard-shortcuts.ts
+++ b/packages/control-plane/src/routes/keyboard-shortcuts.ts
@@ -2,30 +2,23 @@ import { updateKeyboardShortcutPreferencesSchema } from "@open-inspect/shared/ty
import { KeyboardShortcutPreferencesStore } from "../db/keyboard-shortcut-preferences";
import type { Env } from "../types";
import {
+ ACTIVE_SELF,
defineRoutes,
error,
json,
parsePattern,
- SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE,
- type RequestContext,
+ SCM_AGNOSTIC_HUMAN_USER_ROUTE,
type Route,
+ type UserRouteContext,
} from "./shared";
-function canonicalUserId(ctx: RequestContext): string | null {
- if (ctx.principal?.kind === "user") return ctx.principal.userId;
- if (ctx.principal?.kind === "service") return ctx.principal.actor?.canonicalUserId ?? null;
- return null;
-}
-
async function getPreferences(
_request: Request,
_env: Env,
_match: RegExpMatchArray,
- ctx: RequestContext
+ ctx: UserRouteContext
): Promise {
- const userId = canonicalUserId(ctx);
- if (!userId) return error("Canonical user required", 403);
- const shortcuts = await new KeyboardShortcutPreferencesStore(ctx.db).get(userId);
+ const shortcuts = await new KeyboardShortcutPreferencesStore(ctx.db).get(ctx.principal.userId);
return json({ shortcuts });
}
@@ -33,10 +26,8 @@ async function updatePreferences(
request: Request,
_env: Env,
_match: RegExpMatchArray,
- ctx: RequestContext
+ ctx: UserRouteContext
): Promise {
- const userId = canonicalUserId(ctx);
- if (!userId) return error("Canonical user required", 403);
let body: unknown;
try {
body = await request.json();
@@ -46,13 +37,23 @@ async function updatePreferences(
const parsed = updateKeyboardShortcutPreferencesSchema.safeParse(body);
if (!parsed.success) return error("Invalid keyboard shortcuts", 400);
const shortcuts = await new KeyboardShortcutPreferencesStore(ctx.db).set(
- userId,
+ ctx.principal.userId,
parsed.data.shortcuts
);
return json({ shortcuts });
}
-export const keyboardShortcutRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, [
- { method: "GET", pattern: parsePattern("/keyboard-shortcuts"), handler: getPreferences },
- { method: "PUT", pattern: parsePattern("/keyboard-shortcuts"), handler: updatePreferences },
+export const keyboardShortcutRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [
+ {
+ method: "GET",
+ pattern: parsePattern("/keyboard-shortcuts"),
+ authorization: ACTIVE_SELF,
+ handler: getPreferences,
+ },
+ {
+ method: "PUT",
+ pattern: parsePattern("/keyboard-shortcuts"),
+ authorization: ACTIVE_SELF,
+ handler: updatePreferences,
+ },
]);
diff --git a/packages/control-plane/src/routes/mcp-servers.ts b/packages/control-plane/src/routes/mcp-servers.ts
index 3b6a2ec9a..311ac3ab5 100644
--- a/packages/control-plane/src/routes/mcp-servers.ts
+++ b/packages/control-plane/src/routes/mcp-servers.ts
@@ -19,6 +19,7 @@ import {
json,
error,
parseJsonBody,
+ requirePermission,
} from "./shared";
const logger = createLogger("router:mcp-servers");
@@ -167,26 +168,31 @@ export const mcpServerRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUT
{
method: "GET",
pattern: parsePattern("/mcp-servers"),
+ authorization: requirePermission("mcp_servers.read"),
handler: handleListMcpServers,
},
{
method: "POST",
pattern: parsePattern("/mcp-servers"),
+ authorization: requirePermission("mcp_servers.manage"),
handler: handleCreateMcpServer,
},
{
method: "GET",
pattern: parsePattern("/mcp-servers/:id"),
+ authorization: requirePermission("mcp_servers.read"),
handler: handleGetMcpServer,
},
{
method: "PUT",
pattern: parsePattern("/mcp-servers/:id"),
+ authorization: requirePermission("mcp_servers.manage"),
handler: handleUpdateMcpServer,
},
{
method: "DELETE",
pattern: parsePattern("/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 3268b8c8e..cfcfe38fa 100644
--- a/packages/control-plane/src/routes/model-preferences.ts
+++ b/packages/control-plane/src/routes/model-preferences.ts
@@ -15,6 +15,8 @@ import {
json,
error,
parseJsonBody,
+ activeGlobal,
+ requirePermission,
} from "./shared";
const logger = createLogger("router:model-preferences");
@@ -109,11 +111,15 @@ export const modelPreferencesRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVI
{
method: "GET",
pattern: parsePattern("/model-preferences"),
+ authorization: activeGlobal({
+ actorlessGrants: [{ service: "slack-bot" }],
+ }),
handler: handleGetModelPreferences,
},
{
method: "PUT",
pattern: parsePattern("/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 5a1725ee8..f854c2344 100644
--- a/packages/control-plane/src/routes/model-provider-accounts.ts
+++ b/packages/control-plane/src/routes/model-provider-accounts.ts
@@ -56,6 +56,8 @@ import {
type Route,
type SandboxRouteContext,
type UserRouteContext,
+ NO_AUTHORIZATION,
+ requirePermission,
} from "./shared";
const PRIVATE_NO_STORE = "private, no-store" as const;
@@ -182,6 +184,9 @@ function managementRoute(
method,
pattern: parsePattern(path),
cacheControl: PRIVATE_NO_STORE,
+ authorization: requirePermission(
+ method === "GET" ? "provider_accounts.read" : "provider_accounts.manage"
+ ),
handler,
});
}
@@ -481,6 +486,7 @@ export const modelProviderAccountRoutes: Route[] = [
method: "POST",
pattern: parsePattern("/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
new file mode 100644
index 000000000..01ed45447
--- /dev/null
+++ b/packages/control-plane/src/routes/rbac.ts
@@ -0,0 +1,114 @@
+import { AuthorizationError, AuthorizationService } from "../authorization/service";
+import type { Env } from "../types";
+import type { Route } from "./shared";
+import {
+ AUTHENTICATED_USER,
+ SCM_AGNOSTIC_HUMAN_USER_ROUTE,
+ defineRoutes,
+ error,
+ json,
+ requirePermission,
+ type UserRouteContext,
+} from "./shared";
+
+function rbacErrorResponse(cause: unknown): Response {
+ if (cause instanceof AuthorizationError) {
+ return json(
+ {
+ error: "Forbidden",
+ code: cause.code,
+ ...(cause.permission ? { permission: cause.permission } : {}),
+ },
+ cause.status
+ );
+ }
+ return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503);
+}
+
+async function handleGetCurrentAuthorization(
+ _request: Request,
+ _env: Env,
+ _match: RegExpMatchArray,
+ ctx: UserRouteContext
+): Promise {
+ const service = new AuthorizationService(ctx.db);
+ try {
+ return json(await service.getEffectiveAuthorization(ctx.principal.userId));
+ } catch (cause) {
+ return rbacErrorResponse(cause);
+ }
+}
+
+async function handleListRoles(
+ _request: Request,
+ _env: Env,
+ _match: RegExpMatchArray,
+ ctx: UserRouteContext
+): Promise {
+ const service = new AuthorizationService(ctx.db);
+ try {
+ return json(await service.listRoles());
+ } catch (cause) {
+ return rbacErrorResponse(cause);
+ }
+}
+
+async function handleGetRole(
+ _request: Request,
+ _env: Env,
+ match: RegExpMatchArray,
+ ctx: UserRouteContext
+): Promise {
+ const service = new AuthorizationService(ctx.db);
+ try {
+ const role = await service.getRole(decodeURIComponent(match.groups!.id));
+ return role ? json(role) : error("Role not found", 404);
+ } catch (cause) {
+ return rbacErrorResponse(cause);
+ }
+}
+
+async function handleListMembers(
+ _request: Request,
+ _env: Env,
+ _match: RegExpMatchArray,
+ ctx: UserRouteContext
+): Promise {
+ const service = new AuthorizationService(ctx.db);
+ try {
+ return json(await service.listMembers());
+ } catch (cause) {
+ return rbacErrorResponse(cause);
+ }
+}
+
+export const rbacRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [
+ {
+ method: "GET",
+ pattern: /^\/me\/authorization$/,
+ authorization: AUTHENTICATED_USER,
+ cacheControl: "private, no-store",
+ handler: handleGetCurrentAuthorization,
+ },
+ {
+ method: "GET",
+ pattern: /^\/roles$/,
+ authorization: requirePermission("workspace.roles.read"),
+ cacheControl: "private, no-store",
+ handler: handleListRoles,
+ },
+ {
+ method: "GET",
+ pattern: /^\/roles\/(?[^/]+)$/,
+ authorization: requirePermission("workspace.roles.read"),
+ cacheControl: "private, no-store",
+ handler: handleGetRole,
+ },
+ {
+ method: "GET",
+ pattern: /^\/members$/,
+ authorization: requirePermission("workspace.members.read"),
+ cacheControl: "private, no-store",
+ handler: handleListMembers,
+ },
+]);
diff --git a/packages/control-plane/src/routes/repos.ts b/packages/control-plane/src/routes/repos.ts
index b5fc4ce60..b936ad41e 100644
--- a/packages/control-plane/src/routes/repos.ts
+++ b/packages/control-plane/src/routes/repos.ts
@@ -24,6 +24,7 @@ import {
error,
extractRepoParams,
createRouteSourceControlProvider,
+ requirePermission,
} from "./shared";
const logger = createLogger("router:repos");
@@ -329,21 +330,29 @@ export const reposRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [
{
method: "GET",
pattern: parsePattern("/repos"),
+ authorization: requirePermission("repositories.read", {
+ actorlessGrants: [{ service: "slack-bot" }, { service: "linear-bot" }],
+ }),
handler: handleListRepos,
},
{
method: "PUT",
pattern: parsePattern("/repos/:owner/:name/metadata"),
+ authorization: requirePermission("repositories.settings.manage"),
handler: handleUpdateRepoMetadata,
},
{
method: "GET",
pattern: parsePattern("/repos/:owner/:name/metadata"),
+ authorization: requirePermission("repositories.read", {
+ actorlessGrants: [{ service: "github-bot" }],
+ }),
handler: handleGetRepoMetadata,
},
{
method: "GET",
pattern: parsePattern("/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 98e6fd2cf..df5c3476e 100644
--- a/packages/control-plane/src/routes/scm-settings.ts
+++ b/packages/control-plane/src/routes/scm-settings.ts
@@ -25,6 +25,7 @@ import {
error,
parseJsonBody,
extractRepoParams,
+ requirePermission,
} from "./shared";
const logger = createLogger("router:scm-settings");
@@ -222,18 +223,40 @@ async function handleDeleteRepoSettings(
}
export const scmSettingsRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, [
- { method: "GET", pattern: parsePattern("/scm-settings"), handler: handleGetGlobal },
- { method: "PUT", pattern: parsePattern("/scm-settings"), handler: handleSetGlobal },
- { method: "DELETE", pattern: parsePattern("/scm-settings"), handler: handleDeleteGlobal },
- { method: "GET", pattern: parsePattern("/scm-settings/repos"), handler: handleListRepoSettings },
+ {
+ method: "GET",
+ pattern: parsePattern("/scm-settings"),
+ authorization: requirePermission("integrations.read"),
+ handler: handleGetGlobal,
+ },
+ {
+ method: "PUT",
+ pattern: parsePattern("/scm-settings"),
+ authorization: requirePermission("scm_settings.manage"),
+ handler: handleSetGlobal,
+ },
+ {
+ method: "DELETE",
+ pattern: parsePattern("/scm-settings"),
+ authorization: requirePermission("scm_settings.manage"),
+ handler: handleDeleteGlobal,
+ },
+ {
+ method: "GET",
+ pattern: parsePattern("/scm-settings/repos"),
+ authorization: requirePermission("integrations.read"),
+ handler: handleListRepoSettings,
+ },
{
method: "PUT",
pattern: parsePattern("/scm-settings/repos/:owner/:name"),
+ authorization: requirePermission("scm_settings.manage"),
handler: handleSetRepoSettings,
},
{
method: "DELETE",
pattern: parsePattern("/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 25f1fc3ff..b727a9659 100644
--- a/packages/control-plane/src/routes/secrets.ts
+++ b/packages/control-plane/src/routes/secrets.ts
@@ -18,6 +18,7 @@ import {
parseJsonBody,
extractRepoParams,
resolveRepoOrError,
+ requirePermission,
} from "./shared";
import { secretsRequestBodySchema } from "./secret-request-schemas";
@@ -380,31 +381,37 @@ export const secretsRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE,
{
method: "PUT",
pattern: parsePattern("/repos/:owner/:name/secrets"),
+ authorization: requirePermission("repositories.secrets.manage"),
handler: handleSetRepoSecrets,
},
{
method: "GET",
pattern: parsePattern("/repos/:owner/:name/secrets"),
+ authorization: requirePermission("repositories.secrets.manage"),
handler: handleListRepoSecrets,
},
{
method: "DELETE",
pattern: parsePattern("/repos/:owner/:name/secrets/:key"),
+ authorization: requirePermission("repositories.secrets.manage"),
handler: handleDeleteRepoSecret,
},
{
method: "PUT",
pattern: parsePattern("/secrets"),
+ authorization: requirePermission("global_secrets.manage"),
handler: handleSetGlobalSecrets,
},
{
method: "GET",
pattern: parsePattern("/secrets"),
+ authorization: requirePermission("global_secrets.manage"),
handler: handleListGlobalSecrets,
},
{
method: "DELETE",
pattern: parsePattern("/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 519cbacc3..52f1b7e3d 100644
--- a/packages/control-plane/src/routes/session-attachments.ts
+++ b/packages/control-plane/src/routes/session-attachments.ts
@@ -51,6 +51,7 @@ import {
GITHUB_USER_OR_SERVICE_ROUTE,
json,
parsePattern,
+ requirePermission,
type Route,
} from "./shared";
import { sessionRoute, type SessionRouteContext } from "./session-route";
@@ -243,6 +244,7 @@ export const sessionAttachmentRoutes: Route[] = [
sessionRoute({
method: "POST",
pattern: parsePattern("/sessions/:id/attachments"),
+ authorization: requirePermission("sessions.collaborate"),
handler: handleAttachmentPost,
})
),
@@ -251,6 +253,7 @@ export const sessionAttachmentRoutes: Route[] = [
sessionRoute({
method: "GET",
pattern: parsePattern("/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 9d077e9af..ce0541ebf 100644
--- a/packages/control-plane/src/routes/session-child-spawn.ts
+++ b/packages/control-plane/src/routes/session-child-spawn.ts
@@ -33,6 +33,8 @@ import {
GITHUB_SANDBOX_FALLBACK_ROUTE,
json,
parsePattern,
+ permissionRequirement,
+ requireAll,
type Route,
} from "./shared";
import { sessionRoute, type SessionRouteContext } from "./session-route";
@@ -346,6 +348,10 @@ export const sessionChildSpawnRoutes: Route[] = defineRoutes(GITHUB_SANDBOX_FALL
sessionRoute({
method: "POST",
pattern: parsePattern("/sessions/:id/children"),
+ authorization: requireAll(
+ permissionRequirement("sessions.create"),
+ permissionRequirement("sessions.collaborate")
+ ),
handler: handleSpawnChild,
}),
]);
diff --git a/packages/control-plane/src/routes/session-children.ts b/packages/control-plane/src/routes/session-children.ts
index ee1aa2f22..1cfcf250b 100644
--- a/packages/control-plane/src/routes/session-children.ts
+++ b/packages/control-plane/src/routes/session-children.ts
@@ -16,7 +16,9 @@ import {
error,
GITHUB_SANDBOX_FALLBACK_ROUTE,
json,
+ NO_AUTHORIZATION,
parsePattern,
+ requirePermission,
SCM_AGNOSTIC_SANDBOX_ROUTE,
type RequestContext,
type Route,
@@ -263,6 +265,7 @@ export const sessionChildRoutes: Route[] = [
defineRoute(GITHUB_SANDBOX_FALLBACK_ROUTE, {
method: "GET",
pattern: parsePattern("/sessions/:id/children"),
+ authorization: requirePermission("sessions.read"),
handler: handleListChildren,
}),
defineRoute(
@@ -270,6 +273,7 @@ export const sessionChildRoutes: Route[] = [
sessionRoute({
method: "GET",
pattern: parsePattern("/sessions/:id/children/:childId"),
+ authorization: requirePermission("sessions.read"),
handler: handleGetChild,
})
),
@@ -278,6 +282,7 @@ export const sessionChildRoutes: Route[] = [
sessionRoute({
method: "POST",
pattern: parsePattern("/sessions/:id/children/:childId/cancel"),
+ authorization: requirePermission("sessions.lifecycle"),
handler: handleCancelChild,
})
),
@@ -286,6 +291,7 @@ export const sessionChildRoutes: Route[] = [
sessionRoute({
method: "POST",
pattern: parsePattern("/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 7c76ab63d..a9adc0f75 100644
--- a/packages/control-plane/src/routes/session-create.ts
+++ b/packages/control-plane/src/routes/session-create.ts
@@ -30,6 +30,7 @@ import {
type Route,
GITHUB_USER_OR_SERVICE_ROUTE,
defineRoutes,
+ requirePermission,
} from "./shared";
const logger = createLogger("router:session-create");
@@ -65,6 +66,26 @@ async function handleCreateSession(
throw e;
}
+ if (ctx.principal?.kind === "user" || ctx.principal?.kind === "service") {
+ const authorization = ctx.authorization;
+ if (!authorization) return json({ error: "Authorization unavailable" }, 503);
+ if (body.environmentId && !authorization.permissions.includes("environments.use")) {
+ return json(
+ { error: "Forbidden", code: "permission_required", permission: "environments.use" },
+ 403
+ );
+ }
+ if (
+ (repositoryContext || body.repositories) &&
+ !authorization.permissions.includes("repositories.use")
+ ) {
+ return json(
+ { error: "Forbidden", code: "permission_required", permission: "repositories.use" },
+ 403
+ );
+ }
+ }
+
// Validate branch names if provided (defense in depth)
if (body.branch && !BRANCH_NAME_PATTERN.test(body.branch)) {
return error("Invalid branch name");
@@ -266,6 +287,7 @@ export const sessionCreateRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_
{
method: "POST",
pattern: parsePattern("/sessions"),
+ authorization: requirePermission("sessions.create"),
handler: handleCreateSession,
},
]);
diff --git a/packages/control-plane/src/routes/session-diffs.ts b/packages/control-plane/src/routes/session-diffs.ts
index 9c07780f5..3a1fa4603 100644
--- a/packages/control-plane/src/routes/session-diffs.ts
+++ b/packages/control-plane/src/routes/session-diffs.ts
@@ -11,6 +11,7 @@ import {
error,
SCM_AGNOSTIC_SANDBOX_FALLBACK_ROUTE,
SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE,
+ requirePermission,
parsePattern,
type Route,
} from "./shared";
@@ -193,6 +194,7 @@ export const sessionDiffRoutes: Route[] = [
sessionRoute({
method: "GET",
pattern: parsePattern("/sessions/:id/diff"),
+ authorization: requirePermission("sessions.read"),
handler: handleDiffState,
})
),
@@ -201,6 +203,7 @@ export const sessionDiffRoutes: Route[] = [
sessionRoute({
method: "PUT",
pattern: parsePattern("/sessions/:id/diff"),
+ authorization: requirePermission("sessions.collaborate"),
handler: handleDiffUpload,
})
),
@@ -209,6 +212,7 @@ export const sessionDiffRoutes: Route[] = [
sessionRoute({
method: "POST",
pattern: parsePattern("/sessions/:id/diff/failure"),
+ authorization: requirePermission("sessions.collaborate"),
handler: handleDiffFailure,
})
),
@@ -217,6 +221,7 @@ export const sessionDiffRoutes: Route[] = [
sessionRoute({
method: "GET",
pattern: parsePattern("/sessions/:id/diff/:revisionId/files/:fileId"),
+ authorization: requirePermission("sessions.read"),
handler: handleDiffFile,
})
),
@@ -225,6 +230,7 @@ export const sessionDiffRoutes: Route[] = [
sessionRoute({
method: "POST",
pattern: parsePattern("/sessions/:id/diff/retry"),
+ authorization: requirePermission("sessions.lifecycle"),
handler: handleDiffRetry,
})
),
diff --git a/packages/control-plane/src/routes/session-index.test.ts b/packages/control-plane/src/routes/session-index.test.ts
index c8c23da54..5922306b0 100644
--- a/packages/control-plane/src/routes/session-index.test.ts
+++ b/packages/control-plane/src/routes/session-index.test.ts
@@ -9,7 +9,6 @@ import { TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support";
const mockSessionIndexStore = {
list: vi.fn(),
delete: vi.fn(),
- getVisibleForUser: vi.fn(),
updateReadState: vi.fn(),
};
@@ -20,10 +19,21 @@ vi.mock("../db/session-index", () => ({
}));
function createCtx(principal?: Principal): RequestContext {
+ const statement = {
+ bind: vi.fn(() => statement),
+ first: vi.fn(async () => ({
+ user_id: "user-1",
+ suspended_at: null,
+ role_id: "role_builtin_owner",
+ role_key: "owner",
+ role_name: "Owner",
+ })),
+ all: vi.fn(async () => ({ results: [] })),
+ };
return {
trace_id: "trace-1",
request_id: "req-1",
- db: {} as SqlDatabase,
+ db: { prepare: vi.fn(() => statement) } as unknown as SqlDatabase,
executionCtx: TEST_BACKGROUND_TASK_CONTEXT,
metrics: {
d1Queries: [],
@@ -32,6 +42,16 @@ function createCtx(principal?: Principal): RequestContext {
summarize: () => ({}),
},
principal,
+ ...(principal?.kind === "user"
+ ? {
+ authorization: {
+ userId: principal.userId,
+ suspendedAt: null,
+ role: { id: "role_builtin_owner", key: "owner" as const, name: "Owner" },
+ permissions: ["sessions.read", "sessions.delete", "sessions.lifecycle"] as const,
+ },
+ }
+ : {}),
};
}
@@ -84,7 +104,6 @@ describe("session index routes", () => {
sessions: [],
hasMore: false,
});
- mockSessionIndexStore.getVisibleForUser.mockResolvedValue({ id: "session-1" });
mockSessionIndexStore.updateReadState.mockResolvedValue({
sessionId: "session-1",
outcome: "marked_read",
@@ -293,18 +312,6 @@ describe("session index routes", () => {
expect(mockSessionIndexStore.updateReadState).not.toHaveBeenCalled();
});
- it("does not expose invisible sessions through read-state mutations", async () => {
- mockSessionIndexStore.getVisibleForUser.mockResolvedValue(null);
-
- const response = await patchReadState(JSON.stringify({ action: "mark_latest_message_read" }), {
- kind: "user",
- userId: "user-1",
- });
-
- expect(response.status).toBe(404);
- expect(mockSessionIndexStore.updateReadState).not.toHaveBeenCalled();
- });
-
it.each([
[
JSON.stringify({ action: "mark_latest_message_read" }),
@@ -325,7 +332,6 @@ describe("session index routes", () => {
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
- expect(mockSessionIndexStore.getVisibleForUser).toHaveBeenCalledWith("session-1", "user-1");
expect(mockSessionIndexStore.updateReadState).toHaveBeenCalledWith(
"user-1",
"session-1",
diff --git a/packages/control-plane/src/routes/session-index.ts b/packages/control-plane/src/routes/session-index.ts
index 651e3ed21..e12522ce8 100644
--- a/packages/control-plane/src/routes/session-index.ts
+++ b/packages/control-plane/src/routes/session-index.ts
@@ -19,6 +19,7 @@ import {
parseJsonBody,
parsePattern,
SCM_AGNOSTIC_HUMAN_USER_ROUTE,
+ requirePermission,
type RequestContext,
type Route,
type UserRouteContext,
@@ -32,18 +33,13 @@ const SESSION_INBOX_LIMIT = 20;
function parseCreatedByFilters(
values: readonly string[],
- principal: RequestContext["principal"]
+ currentUserId: string | null
): string[] | Response {
const userIds: string[] = [];
const seen = new Set();
for (const value of values) {
- const userId =
- value === SESSION_LIST_CURRENT_USER
- ? principal?.kind === "user"
- ? principal.userId
- : null
- : value;
+ const userId = value === SESSION_LIST_CURRENT_USER ? currentUserId : value;
if (!isCanonicalUserId(userId)) {
return error("Invalid createdBy", 400);
@@ -70,7 +66,13 @@ async function handleListSessions(
const { createdBy, status, excludeStatus, excludeAutomationLineage, limit, offset } =
parsedQuery.data;
- const createdByUserIds = parseCreatedByFilters(createdBy, ctx.principal);
+ const viewerUserId =
+ ctx.principal?.kind === "user"
+ ? ctx.principal.userId
+ : ctx.principal?.kind === "service"
+ ? (ctx.principal.actor?.canonicalUserId ?? ctx.authorization?.userId)
+ : undefined;
+ const createdByUserIds = parseCreatedByFilters(createdBy, viewerUserId ?? null);
if (createdByUserIds instanceof Response) {
return createdByUserIds;
@@ -78,7 +80,6 @@ async function handleListSessions(
const store = new SessionIndexStore(ctx.db);
const listStartedAt = Date.now();
- const viewerUserId = ctx.principal?.kind === "user" ? ctx.principal.userId : undefined;
const result = await store.list({
status,
excludeStatus,
@@ -86,7 +87,7 @@ async function handleListSessions(
createdByUserIds,
limit,
offset,
- viewerUserId,
+ ...(viewerUserId ? { viewerUserId } : {}),
});
if (viewerUserId) {
log.info("session_read_state.decorated", {
@@ -203,9 +204,6 @@ async function handlePatchReadState(
const body = parsedBody.data;
const store = new SessionIndexStore(ctx.db);
- const visibleSession = await store.getVisibleForUser(sessionId, ctx.principal.userId);
- if (!visibleSession) return error("Session not found", 404);
-
const result = await store.updateReadState(ctx.principal.userId, sessionId, body);
if (!result) return error("Session not found", 404);
@@ -242,21 +240,25 @@ export const sessionIndexRoutes: Route[] = [
defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, {
method: "GET",
pattern: parsePattern("/sessions"),
+ authorization: requirePermission("sessions.read"),
handler: handleListSessions,
}),
defineRoute(SCM_AGNOSTIC_HUMAN_USER_ROUTE, {
method: "GET",
pattern: parsePattern("/sessions/inbox"),
+ authorization: requirePermission("sessions.read", { service: "deny" }),
handler: handleListSessionInbox,
}),
defineRoute(SCM_AGNOSTIC_HUMAN_USER_ROUTE, {
method: "PATCH",
pattern: parsePattern("/sessions/:id/read-state"),
+ authorization: requirePermission("sessions.read"),
handler: handlePatchReadState,
}),
defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, {
method: "DELETE",
pattern: parsePattern("/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 0664e4140..6204d75b5 100644
--- a/packages/control-plane/src/routes/session-media-stream.ts
+++ b/packages/control-plane/src/routes/session-media-stream.ts
@@ -15,6 +15,7 @@ import {
error,
GITHUB_USER_OR_SERVICE_ROUTE,
parsePattern,
+ requirePermission,
type Route,
} from "./shared";
import { sessionRoute, type SessionRouteContext } from "./session-route";
@@ -145,6 +146,9 @@ export const sessionMediaStreamRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SER
sessionRoute({
method: "GET",
pattern: parsePattern("/sessions/:id/media/:artifactId"),
+ authorization: requirePermission("sessions.read", {
+ actorlessGrants: [{ service: "slack-bot" }],
+ }),
handler: handleMediaGet,
}),
]);
diff --git a/packages/control-plane/src/routes/session-media-upload.ts b/packages/control-plane/src/routes/session-media-upload.ts
index 44b16acd6..77164f10f 100644
--- a/packages/control-plane/src/routes/session-media-upload.ts
+++ b/packages/control-plane/src/routes/session-media-upload.ts
@@ -27,6 +27,7 @@ import {
GITHUB_SANDBOX_FALLBACK_ROUTE,
json,
parsePattern,
+ requirePermission,
type Route,
} from "./shared";
import { sessionRoute, type SessionRouteContext } from "./session-route";
@@ -250,6 +251,7 @@ export const sessionMediaUploadRoutes: Route[] = defineRoutes(GITHUB_SANDBOX_FAL
sessionRoute({
method: "POST",
pattern: parsePattern("/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 39dd03a73..5916dfeea 100644
--- a/packages/control-plane/src/routes/session-prompt.ts
+++ b/packages/control-plane/src/routes/session-prompt.ts
@@ -26,6 +26,7 @@ import {
error,
GITHUB_USER_OR_SERVICE_ROUTE,
parsePattern,
+ requirePermission,
type Route,
} from "./shared";
import { sessionRoute, type SessionRouteContext } from "./session-route";
@@ -181,6 +182,7 @@ export const sessionPromptRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_
sessionRoute({
method: "POST",
pattern: parsePattern("/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 df6a8e4d5..dd56af8c3 100644
--- a/packages/control-plane/src/routes/session-pull-requests.ts
+++ b/packages/control-plane/src/routes/session-pull-requests.ts
@@ -5,6 +5,7 @@ import {
error,
GITHUB_USER_OR_SERVICE_ROUTE,
parsePattern,
+ requirePermission,
type Route,
} from "./shared";
import { sessionRoute, type SessionRouteContext } from "./session-route";
@@ -33,6 +34,7 @@ export const sessionPullRequestRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SER
sessionRoute({
method: "POST",
pattern: parsePattern("/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 8ccfe5c86..01144fb8b 100644
--- a/packages/control-plane/src/routes/session-runtime-proxy.ts
+++ b/packages/control-plane/src/routes/session-runtime-proxy.ts
@@ -15,8 +15,10 @@ import {
error,
GITHUB_SANDBOX_FALLBACK_ROUTE,
GITHUB_USER_OR_SERVICE_ROUTE,
+ NO_AUTHORIZATION,
parseJsonBody,
parsePattern,
+ requirePermission,
SCM_AGNOSTIC_SANDBOX_FALLBACK_ROUTE,
SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE,
SCM_AGNOSTIC_SANDBOX_ROUTE,
@@ -24,6 +26,7 @@ import {
SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE,
SCM_CREDENTIALS_ROUTE,
type Route,
+ type RouteAuthorization,
type RoutePolicy,
} from "./shared";
import { sessionRoute, type SessionRouteContext } from "./session-route";
@@ -44,6 +47,7 @@ type SimpleProxyRouteConfig = {
method: string;
routePath: string;
internalPath: SessionInternalPath;
+ authorization: RouteAuthorization;
runtimeMethod?: string;
forwardSearch?: boolean;
notFoundMessage?: string;
@@ -64,6 +68,7 @@ function simpleProxyRoute(config: SimpleProxyRouteConfig): Route {
sessionRoute({
method: config.method,
pattern: parsePattern(config.routePath),
+ authorization: config.authorization,
handler: async (request, _env, match, ctx) => {
const sessionId = getSessionId(match);
if (sessionId instanceof Response) return sessionId;
@@ -95,6 +100,7 @@ function legacyTokenRefreshRoute(
sessionRoute({
method: "POST",
pattern: parsePattern(routePath),
+ authorization: NO_AUTHORIZATION,
handler: async (_request, _env, match, ctx) => {
const sessionId = getSessionId(match);
if (sessionId instanceof Response) return sessionId;
@@ -252,12 +258,7 @@ async function handleCreatePR(
});
}
-/**
- * Read a lifecycle-route body (title/archive/unarchive) under identity
- * enforcement. Lifecycle routes accept bodyless requests — a parse failure
- * just yields no fields. The DO participant check runs against the verified
- * identity, never a caller-asserted one.
- */
+/** Read a lifecycle body under verified identity enforcement. */
async function readEnforcedLifecycleBody(
request: Request,
ctx: SessionRouteContext
@@ -286,6 +287,7 @@ function lifecycleProxyRoute(
sessionRoute({
method,
pattern: parsePattern(routePath),
+ authorization: requirePermission("sessions.lifecycle"),
handler: async (request, _env, match, ctx) => {
const sessionId = getSessionId(match);
if (sessionId instanceof Response) return sessionId;
@@ -311,12 +313,14 @@ export const sessionRuntimeProxyRoutes: Route[] = [
method: "GET",
routePath: "/sessions/:id/sandbox-access",
internalPath: SessionInternalPaths.sandboxAccess,
+ authorization: requirePermission("sessions.sandbox_access"),
}),
simpleProxyRoute({
policy: SCM_AGNOSTIC_HUMAN_USER_ROUTE,
method: "GET",
routePath: "/sessions/:id",
internalPath: SessionInternalPaths.snapshot,
+ authorization: requirePermission("sessions.read"),
notFoundMessage: "Session not found",
}),
simpleProxyRoute({
@@ -324,6 +328,9 @@ export const sessionRuntimeProxyRoutes: Route[] = [
method: "POST",
routePath: "/sessions/:id/stop",
internalPath: SessionInternalPaths.stop,
+ authorization: requirePermission("sessions.lifecycle", {
+ actorlessGrants: [{ service: "linear-bot" }],
+ }),
runtimeMethod: "POST",
}),
defineRoute(
@@ -331,6 +338,7 @@ export const sessionRuntimeProxyRoutes: Route[] = [
sessionRoute({
method: "POST",
pattern: parsePattern("/sessions/:id/sandbox-error"),
+ authorization: NO_AUTHORIZATION,
handler: handleSandboxError,
})
),
@@ -339,6 +347,7 @@ export const sessionRuntimeProxyRoutes: Route[] = [
method: "GET",
routePath: "/sessions/:id/events",
internalPath: SessionInternalPaths.events,
+ authorization: requirePermission("sessions.read"),
forwardSearch: true,
}),
simpleProxyRoute({
@@ -346,18 +355,21 @@ export const sessionRuntimeProxyRoutes: Route[] = [
method: "GET",
routePath: "/sessions/:id/artifacts",
internalPath: SessionInternalPaths.artifacts,
+ authorization: requirePermission("sessions.read"),
}),
simpleProxyRoute({
policy: GITHUB_USER_OR_SERVICE_ROUTE,
method: "GET",
routePath: "/sessions/:id/participants",
internalPath: SessionInternalPaths.participants,
+ authorization: requirePermission("sessions.read"),
}),
defineRoute(
SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE,
sessionRoute({
method: "GET",
pattern: parsePattern("/sessions/:id/participant-profiles"),
+ authorization: requirePermission("sessions.read"),
handler: handleParticipantProfiles,
})
),
@@ -366,6 +378,7 @@ export const sessionRuntimeProxyRoutes: Route[] = [
sessionRoute({
method: "POST",
pattern: parsePattern("/sessions/:id/participants"),
+ authorization: requirePermission("sessions.collaborate"),
handler: handleAddParticipant,
})
),
@@ -374,6 +387,7 @@ export const sessionRuntimeProxyRoutes: Route[] = [
method: "GET",
routePath: "/sessions/:id/messages",
internalPath: SessionInternalPaths.messages,
+ authorization: requirePermission("sessions.read"),
forwardSearch: true,
}),
defineRoute(
@@ -381,6 +395,7 @@ export const sessionRuntimeProxyRoutes: Route[] = [
sessionRoute({
method: "POST",
pattern: parsePattern("/sessions/:id/pr"),
+ authorization: requirePermission("sessions.collaborate"),
handler: handleCreatePR,
})
),
@@ -399,6 +414,7 @@ export const sessionRuntimeProxyRoutes: Route[] = [
method: "POST",
routePath: "/sessions/:id/scm-credentials",
internalPath: SessionInternalPaths.scmCredentials,
+ authorization: NO_AUTHORIZATION,
runtimeMethod: "POST",
}),
simpleProxyRoute({
@@ -406,6 +422,7 @@ export const sessionRuntimeProxyRoutes: Route[] = [
method: "GET",
routePath: "/sessions/:id/tunnel-urls",
internalPath: SessionInternalPaths.tunnelUrls,
+ authorization: requirePermission("sessions.sandbox_access"),
runtimeMethod: "GET",
}),
lifecycleProxyRoute("PATCH", "/sessions/:id/title", SessionInternalPaths.updateTitle),
diff --git a/packages/control-plane/src/routes/session-skills.ts b/packages/control-plane/src/routes/session-skills.ts
index 37497ff4e..c36cfb989 100644
--- a/packages/control-plane/src/routes/session-skills.ts
+++ b/packages/control-plane/src/routes/session-skills.ts
@@ -8,6 +8,8 @@ import {
error,
json,
parsePattern,
+ NO_AUTHORIZATION,
+ requirePermission,
SCM_AGNOSTIC_SANDBOX_ROUTE,
SCM_AGNOSTIC_HUMAN_USER_ROUTE,
type SandboxRouteContext,
@@ -27,9 +29,6 @@ async function handleSessionSkillsView(
): Promise {
const id = sessionId(match);
if (id instanceof Response) return id;
- if (!(await new SessionIndexStore(ctx.db).getVisibleForUser(id, ctx.principal.userId))) {
- return error("Session not found", 404);
- }
const view = await new SessionSkillStore(ctx.db).getSessionSkillsView(id);
if (!view) return error("Session skill manifest not found", 404);
const response = json(view);
@@ -96,11 +95,13 @@ export const sessionSkillRoutes: Route[] = [
defineRoute(SCM_AGNOSTIC_HUMAN_USER_ROUTE, {
method: "GET",
pattern: parsePattern("/sessions/:id/skills"),
+ authorization: requirePermission("sessions.read"),
handler: handleSessionSkillsView,
}),
defineRoute(SCM_AGNOSTIC_SANDBOX_ROUTE, {
method: "GET",
pattern: parsePattern("/sessions/:id/sandbox-skills"),
+ authorization: NO_AUTHORIZATION,
handler: handleSandboxInstallation,
}),
];
diff --git a/packages/control-plane/src/routes/session-ws-token.test.ts b/packages/control-plane/src/routes/session-ws-token.test.ts
index 4699b48ad..608d5f471 100644
--- a/packages/control-plane/src/routes/session-ws-token.test.ts
+++ b/packages/control-plane/src/routes/session-ws-token.test.ts
@@ -3,6 +3,7 @@ import { TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support";
import { sessionWsTokenRoutes } from "./session-ws-token";
import type { RequestContext, Route } from "./shared";
import type { Env } from "../types";
+import type { SqlDatabase } from "../db/sql-database";
function routeFor(path: string): { route: Route; match: RegExpMatchArray } {
const route = sessionWsTokenRoutes.find((candidate) => candidate.pattern.test(path));
@@ -12,13 +13,32 @@ function routeFor(path: string): { route: Route; match: RegExpMatchArray } {
return { route, match };
}
-function createContext(): RequestContext {
+function accessDatabase() {
+ const run = vi.fn(async () => ({ meta: { changes: 1 } }));
+ const statement = {
+ bind: vi.fn(() => statement),
+ run,
+ };
+ return {
+ db: { prepare: vi.fn(() => statement) } as unknown as SqlDatabase,
+ statement,
+ run,
+ };
+}
+
+function createContext(db: SqlDatabase = accessDatabase().db): RequestContext {
return {
request_id: "request-1",
trace_id: "trace-1",
- db: {} as never,
+ db,
executionCtx: TEST_BACKGROUND_TASK_CONTEXT,
principal: { kind: "user", userId: "user-1" },
+ authorization: {
+ userId: "user-1",
+ suspendedAt: null,
+ role: { id: "role-1", key: "member", name: "Member" },
+ permissions: ["sessions.collaborate"],
+ },
metrics: {
d1Queries: [],
spans: {},
@@ -71,6 +91,25 @@ describe("session ws-token route", () => {
});
});
+ it("forwards a runtime rejection without writing D1", async () => {
+ const access = accessDatabase();
+ const fetch = vi.fn(async () => Response.json({ error: "rejected" }, { status: 409 }));
+ const { route, match } = routeFor("/sessions/session-1/ws-token");
+
+ const response = await route.handler(
+ new Request("https://test.local/sessions/session-1/ws-token", {
+ method: "POST",
+ body: JSON.stringify({}),
+ }),
+ createEnv(fetch),
+ match,
+ createContext(access.db)
+ );
+
+ expect(response.status).toBe(409);
+ expect(access.db.prepare).not.toHaveBeenCalled();
+ });
+
it("forwards null SCM display fields accepted by the session contract", async () => {
const forwarded: Request[] = [];
const fetch = vi.fn(async (request: Request) => {
diff --git a/packages/control-plane/src/routes/session-ws-token.ts b/packages/control-plane/src/routes/session-ws-token.ts
index 4d684b1b7..d1d23bf59 100644
--- a/packages/control-plane/src/routes/session-ws-token.ts
+++ b/packages/control-plane/src/routes/session-ws-token.ts
@@ -7,6 +7,7 @@ import {
GITHUB_USER_OR_SERVICE_ROUTE,
parseJsonBody,
parsePattern,
+ requirePermission,
type Route,
} from "./shared";
import { sessionRoute, type SessionRouteContext } from "./session-route";
@@ -33,8 +34,10 @@ async function handleSessionWsToken(
if (!parsedBody.success) return error("Invalid websocket token body", 400);
const body = parsedBody.data;
+ const authorization = ctx.authorization;
+ if (!authorization) return error("Authorization unavailable", 503);
const userId = enforcement.enforced.participantUserId;
- const canonicalUserId = enforcement.enforced.canonicalUserId;
+ const canonicalUserId = authorization.userId;
return ctx.metrics.time("do_fetch", () =>
ctx.sessionRuntime.fetch(sessionId, SessionInternalPaths.wsToken, {
@@ -55,6 +58,7 @@ export const sessionWsTokenRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE
sessionRoute({
method: "POST",
pattern: parsePattern("/sessions/:id/ws-token"),
+ authorization: requirePermission("sessions.collaborate"),
handler: handleSessionWsToken,
}),
]);
diff --git a/packages/control-plane/src/routes/shared.ts b/packages/control-plane/src/routes/shared.ts
index cf17736d2..0d095666d 100644
--- a/packages/control-plane/src/routes/shared.ts
+++ b/packages/control-plane/src/routes/shared.ts
@@ -11,6 +11,13 @@ 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 { ServiceName } from "@open-inspect/shared/service-auth";
+import type { AutomationRow } from "../db/automation-store";
import {
createSourceControlProviderFromEnv,
SourceControlProviderError,
@@ -19,9 +26,7 @@ import {
type SourceControlProviderName,
} from "../source-control";
-/**
- * Request context with correlation IDs and per-request metrics.
- */
+/** Request-scoped dependencies, identity, and resolved authorization state. */
export type RequestContext = CorrelationContext & {
metrics: RequestMetrics;
/**
@@ -44,18 +49,151 @@ export type RequestContext = CorrelationContext & {
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;
};
-/**
- * Route configuration.
- */
+/** Automation resource admitted by the router for the current mutation. */
+export interface AutomationRouteAdmission {
+ automation: AutomationRow;
+}
+
+/** Route matching, authorization, and handler configuration. */
export interface RouteDefinition {
method: string;
pattern: RegExp;
+ /** Authorization policy enforced before the handler runs. */
+ authorization: RouteAuthorization;
cacheControl?: "no-store" | "private, no-store";
handler: (request: Request, env: Env, match: RegExpMatchArray, ctx: Context) => Promise;
}
+/** One permission or resource-admission requirement for an active user. */
+export type RouteAuthorizationRequirement =
+ | { kind: "permission"; permission: PermissionId }
+ | { kind: "scoped-permission"; stem: ScopedPermissionStem }
+ | {
+ kind: "automation";
+ operation: "manage" | "trigger";
+ automationIdParam: string;
+ };
+
+type BotServiceName = Exclude;
+
+/** Narrow route grant for a trusted service without an acting user. */
+export interface ActorlessServiceGrant {
+ service: BotServiceName;
+ pathParams?: Readonly>;
+}
+
+type ServiceAuthorization =
+ | { kind: "deny" }
+ | {
+ kind: "actor";
+ actorlessGrants?: readonly ActorlessServiceGrant[];
+ };
+
+/** Declarative authorization policy enforced by the router. */
+export type RouteAuthorization =
+ | { kind: "none" }
+ | { kind: "authenticated" }
+ | { kind: "active-self" }
+ | { kind: "active-global"; service: ServiceAuthorization }
+ | {
+ kind: "active-user";
+ allOf: readonly RouteAuthorizationRequirement[];
+ service: ServiceAuthorization;
+ }
+ | {
+ kind: "service";
+ services: readonly BotServiceName[];
+ actor: "required" | "optional";
+ };
+
+/**
+ * Skips router-level permission checks after route authentication.
+ *
+ * The route may still require a service signature, a session-bound sandbox token, or credentials
+ * verified by its handler. Only routes whose authentication policy is `public` are publicly
+ * accessible.
+ */
+export const NO_AUTHORIZATION = { kind: "none" } as const satisfies RouteAuthorization;
+/** Policy requiring any authenticated principal. */
+export const AUTHENTICATED_USER = {
+ kind: "authenticated",
+} as const satisfies RouteAuthorization;
+/** Policy requiring an active user to access their own account resource. */
+export const ACTIVE_SELF = { kind: "active-self" } as const satisfies RouteAuthorization;
+
+/** Build a global permission requirement for composition with other requirements. */
+export function permissionRequirement(permission: PermissionId): RouteAuthorizationRequirement {
+ return { kind: "permission", permission };
+}
+
+/** Require an active user with a global permission, optionally allowing service actors. */
+export function requirePermission(
+ permission: PermissionId,
+ options?: { service?: "actor" | "deny"; actorlessGrants?: readonly ActorlessServiceGrant[] }
+): RouteAuthorization {
+ return {
+ kind: "active-user",
+ allOf: [permissionRequirement(permission)],
+ service:
+ options?.service === "deny"
+ ? { kind: "deny" }
+ : { kind: "actor", actorlessGrants: options?.actorlessGrants },
+ };
+}
+
+/** Require an active user with at least one permission under a scoped stem. */
+export function requireScopedPermission(
+ stem: ScopedPermissionStem,
+ options?: { service?: "actor" }
+): RouteAuthorization {
+ return {
+ kind: "active-user",
+ allOf: [{ kind: "scoped-permission", stem }],
+ service: options?.service === "actor" ? { kind: "actor" } : { kind: "deny" },
+ };
+}
+
+/** Require admission to manage or trigger the automation identified by a path parameter. */
+export function requireAutomation(
+ operation: "manage" | "trigger",
+ automationIdParam = "id"
+): RouteAuthorization {
+ return {
+ kind: "active-user",
+ allOf: [{ kind: "automation", operation, automationIdParam }],
+ service: { kind: "deny" },
+ };
+}
+
+/** Require an active user to satisfy every supplied authorization requirement. */
+export function requireAll(...allOf: readonly RouteAuthorizationRequirement[]): RouteAuthorization {
+ return { kind: "active-user", allOf, service: { kind: "actor" } };
+}
+
+/** Require any active user, with optional actorless service grants. */
+export function activeGlobal(options?: {
+ actorlessGrants?: readonly ActorlessServiceGrant[];
+}): RouteAuthorization {
+ return {
+ kind: "active-global",
+ service: { kind: "actor", actorlessGrants: options?.actorlessGrants },
+ };
+}
+
+/** Restrict a route to one trusted service, with optional actor identity. */
+export function serviceAuthorized(
+ service: BotServiceName,
+ actor: "required" | "optional" = "optional"
+): RouteAuthorization {
+ return { kind: "service", services: [service], actor };
+}
+
type UserPrincipal = Extract;
type SandboxPrincipal = Extract;
type ServicePrincipal = Extract;
diff --git a/packages/control-plane/src/routes/sign-in-providers.ts b/packages/control-plane/src/routes/sign-in-providers.ts
index a28828208..15058e7dd 100644
--- a/packages/control-plane/src/routes/sign-in-providers.ts
+++ b/packages/control-plane/src/routes/sign-in-providers.ts
@@ -4,6 +4,7 @@ import {
defineRoutes,
error,
json,
+ NO_AUTHORIZATION,
parsePattern,
SCM_AGNOSTIC_WEB_SERVICE_ROUTE,
type Route,
@@ -39,6 +40,7 @@ export const signInProviderRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_WEB_SERVI
{
method: "GET",
pattern: parsePattern("/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 942b799fe..84e3c3be3 100644
--- a/packages/control-plane/src/routes/skills.ts
+++ b/packages/control-plane/src/routes/skills.ts
@@ -40,6 +40,7 @@ import {
SCM_AGNOSTIC_HUMAN_USER_ROUTE,
SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE,
defineRoutes,
+ requirePermission,
} from "./shared";
const log = createLogger("router:skills");
@@ -625,63 +626,103 @@ function profileWriteError(value: unknown): Response {
}
const skillReadRoutes = defineRoutes(SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, [
- { method: "GET", pattern: parsePattern("/skills"), handler: handleListSkills },
+ {
+ method: "GET",
+ pattern: parsePattern("/skills"),
+ authorization: requirePermission("skills.read"),
+ handler: handleListSkills,
+ },
{
method: "POST",
pattern: parsePattern("/skills/preview"),
+ authorization: requirePermission("skills.read"),
handler: handlePreviewSkill,
},
{
method: "POST",
pattern: parsePattern("/skills/resolve-preview"),
+ authorization: requirePermission("skills.read"),
handler: handleResolvePreview,
},
- { method: "GET", pattern: parsePattern("/skills/:id"), handler: handleGetSkill },
+ {
+ method: "GET",
+ pattern: parsePattern("/skills/:id"),
+ authorization: requirePermission("skills.read"),
+ handler: handleGetSkill,
+ },
]);
const skillAdministrationRoutes = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [
- { method: "POST", pattern: parsePattern("/skills"), handler: handleCreateSkill },
+ {
+ method: "POST",
+ pattern: parsePattern("/skills"),
+ authorization: requirePermission("skills.manage"),
+ handler: handleCreateSkill,
+ },
{
method: "POST",
pattern: parsePattern("/skills/import/preview"),
+ authorization: requirePermission("skills.manage"),
handler: handlePreviewSkillImport,
},
- { method: "POST", pattern: parsePattern("/skills/import"), handler: handleImportSkill },
+ {
+ method: "POST",
+ pattern: parsePattern("/skills/import"),
+ authorization: requirePermission("skills.manage"),
+ handler: handleImportSkill,
+ },
{
method: "POST",
pattern: parsePattern("/skills/:id/reimport/preview"),
+ authorization: requirePermission("skills.manage"),
handler: handlePreviewSkillReimport,
},
{
method: "POST",
pattern: parsePattern("/skills/:id/reimport"),
+ authorization: requirePermission("skills.manage"),
handler: handleReimportSkill,
},
{
method: "PATCH",
pattern: parsePattern("/skills/:id"),
+ authorization: requirePermission("skills.manage"),
handler: handleSetSkillEnabled,
},
{
method: "PUT",
pattern: parsePattern("/skills/:id"),
+ authorization: requirePermission("skills.manage"),
handler: handleReplaceSkillContentAndAssignments,
},
- { method: "DELETE", pattern: parsePattern("/skills/:id"), handler: handleDeleteSkill },
- { method: "GET", pattern: parsePattern("/skill-profiles"), handler: handleListProfiles },
+ {
+ method: "DELETE",
+ pattern: parsePattern("/skills/:id"),
+ authorization: requirePermission("skills.manage"),
+ handler: handleDeleteSkill,
+ },
+ {
+ method: "GET",
+ pattern: parsePattern("/skill-profiles"),
+ authorization: requirePermission("skill_profiles.manage_own"),
+ handler: handleListProfiles,
+ },
{
method: "POST",
pattern: parsePattern("/skill-profiles"),
+ authorization: requirePermission("skill_profiles.manage_own"),
handler: handleCreateProfile,
},
{
method: "PATCH",
pattern: parsePattern("/skill-profiles/:id"),
+ authorization: requirePermission("skill_profiles.manage_own"),
handler: handleUpdateProfile,
},
{
method: "DELETE",
pattern: parsePattern("/skill-profiles/:id"),
+ authorization: requirePermission("skill_profiles.manage_own"),
handler: handleDeleteProfile,
},
]);
diff --git a/packages/control-plane/src/webhooks/automation-event.ts b/packages/control-plane/src/webhooks/automation-event.ts
index 6d93e46da..a90b8c6c2 100644
--- a/packages/control-plane/src/webhooks/automation-event.ts
+++ b/packages/control-plane/src/webhooks/automation-event.ts
@@ -23,6 +23,7 @@ import {
GITHUB_USER_OR_SERVICE_ROUTE,
json,
parsePattern,
+ serviceAuthorized,
} from "../routes/shared";
import type { Env } from "../types";
import { Scheduler } from "../scheduler/scheduler";
@@ -124,6 +125,7 @@ export async function forwardAutomationEventToScheduler(
return json({ ok: true, ...result });
}
+/** Create an authenticated route for a normalized automation event source. */
export function createAutomationEventRoute(opts: {
path: string;
source: AutomationEventSource;
@@ -157,6 +159,7 @@ export function createAutomationEventRoute(opts: {
return defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, {
method: "POST",
pattern: parsePattern(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 b715b69a8..ebcd1ca6f 100644
--- a/packages/control-plane/src/webhooks/automation-webhook.ts
+++ b/packages/control-plane/src/webhooks/automation-webhook.ts
@@ -10,6 +10,7 @@ import {
defineRoute,
error,
json,
+ NO_AUTHORIZATION,
parsePattern,
SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE,
} from "../routes/shared";
@@ -90,5 +91,6 @@ async function handleAutomationWebhook(
export const automationWebhookRoute: Route = defineRoute(SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE, {
method: "POST",
pattern: parsePattern("/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 d2e2c7eef..48252fef1 100644
--- a/packages/control-plane/src/webhooks/github.ts
+++ b/packages/control-plane/src/webhooks/github.ts
@@ -14,7 +14,13 @@ 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_USER_OR_SERVICE_ROUTE, parsePattern } from "../routes/shared";
+import {
+ defineRoute,
+ error,
+ GITHUB_USER_OR_SERVICE_ROUTE,
+ parsePattern,
+ serviceAuthorized,
+} from "../routes/shared";
import { requireEventPoster } from "../auth/identity-enforcement";
import {
forwardAutomationEventToScheduler,
@@ -127,5 +133,6 @@ async function handleGitHubAutomationEvent(
export const githubAutomationEventRoute: Route = defineRoute(GITHUB_USER_OR_SERVICE_ROUTE, {
method: "POST",
pattern: parsePattern("/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 ebd410341..484ff1a8d 100644
--- a/packages/control-plane/src/webhooks/sentry.ts
+++ b/packages/control-plane/src/webhooks/sentry.ts
@@ -12,6 +12,7 @@ import {
defineRoute,
error,
json,
+ NO_AUTHORIZATION,
parsePattern,
SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE,
} from "../routes/shared";
@@ -122,5 +123,6 @@ async function handleSentryWebhook(
export const sentryWebhookRoute: Route = defineRoute(SCM_AGNOSTIC_HANDLER_AUTHENTICATED_ROUTE, {
method: "POST",
pattern: parsePattern("/webhooks/sentry/:id"),
+ authorization: NO_AUTHORIZATION,
handler: handleSentryWebhook,
});
diff --git a/packages/control-plane/test/integration/automations-slack-route.test.ts b/packages/control-plane/test/integration/automations-slack-route.test.ts
index 091c4e27f..88dd08330 100644
--- a/packages/control-plane/test/integration/automations-slack-route.test.ts
+++ b/packages/control-plane/test/integration/automations-slack-route.test.ts
@@ -44,13 +44,9 @@ function createBody(overrides: Record) {
}
async function postAutomation(body: Record): Promise {
- // automation-create requires a participant identity: sign as a bot with an
- // asserted actor (the userless web service credential is rejected, 403).
return serviceFetch("https://test.local/automations", {
method: "POST",
body: JSON.stringify(body),
- service: "slack-bot",
- actor: "slack:U0123",
});
}
diff --git a/packages/control-plane/test/integration/helpers.ts b/packages/control-plane/test/integration/helpers.ts
index 32ae6965d..510c758cb 100644
--- a/packages/control-plane/test/integration/helpers.ts
+++ b/packages/control-plane/test/integration/helpers.ts
@@ -2,6 +2,7 @@ import { SELF, env } from "cloudflare:test";
import { runInSessionDO } from "./session-do-access";
import type { SandboxSettings } from "@open-inspect/shared/types/integrations";
import { buildServiceAuthHeaders, type ServiceName } from "@open-inspect/shared/service-auth";
+import { BUILT_IN_ROLE_REGISTRY, type BuiltInRoleKey } from "@open-inspect/shared/rbac";
import type { SandboxStatus } from "@open-inspect/shared/types/sessions";
import type { SessionDO } from "../../src/session/durable-object";
import { hashToken } from "../../src/auth/crypto";
@@ -28,11 +29,22 @@ export function getSetCookies(headers: Headers): string[] {
return (headers as Headers & { getSetCookie(): string[] }).getSetCookie();
}
+export async function seedActiveUser(userId: string): Promise {
+ const now = Date.now();
+ await env.DB.prepare(
+ `INSERT INTO users (id, display_name, created_at, updated_at) VALUES (?, ?, ?, ?)`
+ )
+ .bind(userId, "Integration User", now, now)
+ .run();
+}
+
const DEFAULT_WAIT_FOR_SANDBOX_STATUS_TIMEOUT_MS = 3000;
export const INTEGRATION_WEBSOCKET_TIMEOUT_MS = 2000;
const TEST_BROWSER_USER_ID = "11111111111111111111111111111111";
const TEST_BROWSER_ACCOUNT_ID = "test-browser-account";
const TEST_BROWSER_PROVIDER_SUBJECT = "583231";
+type InitialUserRole = Exclude;
+const DEFAULT_INITIAL_USER_ROLE = "owner" as const;
const TEST_BROWSER_SESSION_ID = "test-browser-session";
const TEST_BROWSER_SESSION_TOKEN = "test-browser-session-token";
const TEST_BROWSER_SESSION_COOKIE = "__Secure-openinspect.session_token";
@@ -69,13 +81,16 @@ async function signCookieValue(value: string, secret: string): Promise {
* web request must carry the same compound credential as production. Direct
* service-auth tests intentionally build their own bare sig1 requests.
*/
-async function testBrowserSessionCookie(): Promise {
+async function testBrowserSessionCookie(initialRole: InitialUserRole): Promise {
const secret = env.BROWSER_AUTH_SECRET;
if (!secret) throw new Error("BROWSER_AUTH_SECRET is not configured for integration tests");
const now = new Date();
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const applicationTimestamp = now.getTime();
+ const existingUser = await env.DB.prepare("SELECT 1 FROM users WHERE id = ?")
+ .bind(TEST_BROWSER_USER_ID)
+ .first();
await env.DB.batch([
env.DB.prepare(
`INSERT OR IGNORE INTO users
@@ -86,7 +101,7 @@ async function testBrowserSessionCookie(): Promise {
"Integration Browser User",
"browser@test.local",
1,
- null,
+ "browser@test.local",
applicationTimestamp,
applicationTimestamp
),
@@ -121,6 +136,11 @@ async function testBrowserSessionCookie(): Promise {
TEST_BROWSER_USER_ID
),
]);
+ if (initialRole !== "member" && !existingUser) {
+ await env.DB.prepare(`UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?`)
+ .bind(BUILT_IN_ROLE_REGISTRY[initialRole].id, TEST_BROWSER_USER_ID)
+ .run();
+ }
const signedToken = await signCookieValue(TEST_BROWSER_SESSION_TOKEN, secret);
return `${TEST_BROWSER_SESSION_COOKIE}=${signedToken}`;
@@ -140,6 +160,7 @@ export async function serviceFetch(
headers?: Record;
service?: ServiceName;
actor?: string;
+ initialUserRole?: InitialUserRole;
}
): Promise {
const method = init?.method ?? "GET";
@@ -152,7 +173,10 @@ export async function serviceFetch(
body: init?.body,
actor: init?.actor,
});
- const browserCookie = service === "web" ? await testBrowserSessionCookie() : undefined;
+ const browserCookie =
+ service === "web"
+ ? await testBrowserSessionCookie(init?.initialUserRole ?? DEFAULT_INITIAL_USER_ROLE)
+ : undefined;
return SELF.fetch(url, {
method,
headers: {
diff --git a/packages/control-plane/test/integration/image-builds.test.ts b/packages/control-plane/test/integration/image-builds.test.ts
index 85480b084..ef7d35314 100644
--- a/packages/control-plane/test/integration/image-builds.test.ts
+++ b/packages/control-plane/test/integration/image-builds.test.ts
@@ -8,7 +8,6 @@
* deployment, and the SCM-less harness split is the same as PR-4/PR-8.
*/
-import { buildServiceAuthHeaders } from "@open-inspect/shared/service-auth";
import { describe, it, expect, beforeEach } from "vitest";
import { SELF, env } from "cloudflare:test";
import { ImageBuildStore } from "../../src/db/image-builds";
@@ -27,6 +26,7 @@ import type { DeleteImageInput, ImageBuildAdapter } from "../../src/image-builds
import { evaluateImageBuildForSpawn } from "../../src/sandbox/lifecycle/image-selection";
import type { Env } from "../../src/types";
import { cleanD1Tables } from "./cleanup";
+import { serviceFetch } from "./helpers";
import {
RUNTIME_VERSION,
REPOSITORY_SHAS,
@@ -62,22 +62,6 @@ const WIRE_KEYS = [
// only forwards token-shaped bearers to the workflow).
const MODAL_BUILD_TOKEN = "ab".repeat(32);
-/** Call an internal route with a registered service credential. */
-async function serviceFetch(url: string, init?: { method?: string; body?: string }) {
- const method = init?.method ?? "GET";
- const headers = {
- ...(await buildServiceAuthHeaders({
- service: "linear-bot",
- secret: "test-service-secret-linear-bot",
- method,
- url,
- body: init?.body,
- })),
- ...(init?.body === undefined ? {} : { "Content-Type": "application/json" }),
- };
- return SELF.fetch(url, { method, headers, body: init?.body });
-}
-
function tokenHeaders(token: string): Record {
return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
}
diff --git a/packages/control-plane/test/integration/service-auth.test.ts b/packages/control-plane/test/integration/service-auth.test.ts
index 4a7680ead..aa299ba17 100644
--- a/packages/control-plane/test/integration/service-auth.test.ts
+++ b/packages/control-plane/test/integration/service-auth.test.ts
@@ -46,7 +46,7 @@ async function signedFetch(p: {
describe("sig1 service-credential authentication", () => {
beforeEach(cleanD1Tables);
- it("accepts a signed GET from every non-web service", async () => {
+ it("rejects actorless service requests on broad routes", async () => {
for (const service of Object.keys(SERVICE_SECRET).filter(
(candidate): candidate is Exclude => candidate !== "web"
)) {
@@ -55,12 +55,81 @@ describe("sig1 service-credential authentication", () => {
method: "GET",
url: "https://test.local/sessions",
});
- expect(response.status, service).toBe(200);
- const body = await response.json<{ sessions: unknown[] }>();
- expect(body.sessions).toEqual([]);
+ expect(response.status, service).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({ code: "service_actor_required" });
}
});
+ it.each([
+ ["slack-bot", "/repos", 200],
+ ["linear-bot", "/repos", 200],
+ ["github-bot", "/repos/acme/widgets/metadata", 200],
+ ["slack-bot", "/environments", 200],
+ ["linear-bot", "/environments", 200],
+ ["github-bot", "/environments/missing", 404],
+ ["slack-bot", "/integration-settings/slack", 200],
+ ["slack-bot", "/integration-settings/slack/watched-channels", 200],
+ ["slack-bot", "/model-preferences", 200],
+ ] as const)(
+ "allows actorless %s metadata/config read %s",
+ async (service, path, expectedStatus) => {
+ if (path === "/repos") {
+ await env.REPOS_CACHE.put(
+ "repos:list:v2",
+ JSON.stringify({
+ repos: [],
+ cachedAt: new Date().toISOString(),
+ freshUntil: Date.now() + 60_000,
+ })
+ );
+ }
+ const response = await signedFetch({
+ service,
+ method: "GET",
+ url: `https://test.local${path}`,
+ });
+ expect(response.status).toBe(expectedStatus);
+ }
+ );
+
+ it("denies an actorless service without the route's exact grant", async () => {
+ const response = await signedFetch({
+ service: "linear-bot",
+ method: "GET",
+ url: "https://test.local/integration-settings/slack",
+ });
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({ code: "service_actor_required" });
+ });
+
+ it("denies actorless resolved settings for the wrong integration", async () => {
+ const response = await signedFetch({
+ service: "github-bot",
+ method: "GET",
+ url: "https://test.local/integration-settings/linear/resolved/acme/widgets",
+ });
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({ code: "service_actor_required" });
+ });
+
+ it.each([
+ ["github-bot", "github"],
+ ["linear-bot", "linear"],
+ ] as const)(
+ "authorizes actorless %s only for matching resolved settings",
+ async (service, id) => {
+ const response = await signedFetch({
+ service,
+ method: "GET",
+ url: `https://test.local/integration-settings/${id}/resolved/missing/repository`,
+ });
+
+ expect(response.status).not.toBe(403);
+ }
+ );
+
it("requires a browser session in addition to the web service channel", async () => {
const response = await signedFetch({
service: "web",
@@ -78,6 +147,7 @@ describe("sig1 service-credential authentication", () => {
secret: SERVICE_SECRET["linear-bot"],
method: "GET",
url: signedUrl,
+ actor: "linear:query-order",
});
const response = await SELF.fetch(
`https://test.local/sessions?createdBy=${createdBy}&limit=5`,
@@ -88,20 +158,20 @@ describe("sig1 service-credential authentication", () => {
expect(response.status).toBe(200);
});
- it("delivers the signed body intact to the handler (D1 write lands)", async () => {
+ it("does not let an actorless service mutate global secrets", async () => {
const response = await signedFetch({
service: "linear-bot",
method: "PUT",
url: "https://test.local/secrets",
body: JSON.stringify({ secrets: { SIGNED_BODY_TEST: "intact" } }),
});
- expect(response.status).toBe(200);
+ expect(response.status).toBe(403);
const secrets = await new GlobalSecretsStore(
env.DB,
env.REPO_SECRETS_ENCRYPTION_KEY!
).getDecryptedSecrets();
- expect(secrets.SIGNED_BODY_TEST).toBe("intact");
+ expect(secrets.SIGNED_BODY_TEST).toBeUndefined();
});
it("rejects a body tampered after signing", async () => {
@@ -113,13 +183,14 @@ describe("sig1 service-credential authentication", () => {
method: "PUT",
url,
body: intactBody,
+ actor: "linear:tamper-test",
});
const intact = await SELF.fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json", ...headers },
body: intactBody,
});
- expect(intact.status).toBe(200);
+ expect(intact.status).toBe(403);
const tampered = await SELF.fetch(url, {
method: "PUT",
@@ -190,7 +261,7 @@ describe("sig1 service-credential authentication", () => {
expect(response.status).toBe(401);
});
- it("persists bot session ownership from the signed actor", async () => {
+ it("persists bot creator attribution and permits cross-actor collaboration", async () => {
const created = await signedFetch({
service: "slack-bot",
method: "POST",
@@ -202,6 +273,7 @@ describe("sig1 service-credential authentication", () => {
}),
});
expect(created.status).toBe(201);
+ const createdBody = await created.json<{ sessionId: string }>();
const identity = await new UserStore(env.DB).getIdentity("slack", "U0001");
expect(identity).not.toBeNull();
@@ -221,6 +293,132 @@ describe("sig1 service-credential authentication", () => {
spawnSource: "slack-bot",
})
);
+
+ const collaboratorList = await signedFetch({
+ service: "slack-bot",
+ method: "GET",
+ url: "https://test.local/sessions",
+ actor: "slack:U0002",
+ });
+ expect(collaboratorList.status).toBe(200);
+ await expect(collaboratorList.json()).resolves.toMatchObject({
+ sessions: [expect.objectContaining({ title: "Slack-owned session" })],
+ });
+
+ const collaborator = await signedFetch({
+ service: "slack-bot",
+ method: "POST",
+ url: `https://test.local/sessions/${createdBody.sessionId}/prompt`,
+ actor: "slack:U0002",
+ body: JSON.stringify({ content: "Cross-session prompt" }),
+ });
+ expect(collaborator.status).toBe(200);
+
+ const deniedByServiceCeiling = await signedFetch({
+ service: "slack-bot",
+ method: "DELETE",
+ url: `https://test.local/sessions/${createdBody.sessionId}`,
+ actor: "slack:U0002",
+ });
+ expect(deniedByServiceCeiling.status).toBe(403);
+ await expect(deniedByServiceCeiling.json()).resolves.toMatchObject({
+ code: "service_capability_required",
+ });
+ });
+
+ it("allows only narrow actorless session callbacks", async () => {
+ const created = await signedFetch({
+ service: "linear-bot",
+ method: "POST",
+ url: "https://test.local/sessions",
+ actor: "linear:U-CREATOR",
+ body: JSON.stringify({
+ title: "Linear callback session",
+ model: "anthropic/claude-haiku-4-5",
+ }),
+ });
+ expect(created.status).toBe(201);
+ const { sessionId } = await created.json<{ sessionId: string }>();
+
+ const linearStop = await signedFetch({
+ service: "linear-bot",
+ method: "POST",
+ url: `https://test.local/sessions/${sessionId}/stop`,
+ });
+ expect(linearStop.status).not.toBe(403);
+
+ const slackMedia = await signedFetch({
+ service: "slack-bot",
+ method: "GET",
+ url: `https://test.local/sessions/${sessionId}/media/missing-artifact`,
+ });
+ expect(slackMedia.status).not.toBe(403);
+
+ const wrongService = await signedFetch({
+ service: "github-bot",
+ method: "POST",
+ url: `https://test.local/sessions/${sessionId}/stop`,
+ });
+ expect(wrongService.status).toBe(403);
+ await expect(wrongService.json()).resolves.toMatchObject({ code: "service_actor_required" });
+ });
+
+ it("denies suspended canonical bot actors and actorless broad requests", async () => {
+ await signedFetch({
+ service: "slack-bot",
+ method: "POST",
+ url: "https://test.local/sessions",
+ actor: "slack:U-SUSPENDED",
+ body: JSON.stringify({ title: "Actor session", model: "anthropic/claude-haiku-4-5" }),
+ });
+ const identity = await new UserStore(env.DB).getIdentity("slack", "U-SUSPENDED");
+ await env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?")
+ .bind(identity!.userId)
+ .run();
+
+ const attributed = await signedFetch({
+ service: "slack-bot",
+ method: "GET",
+ url: "https://test.local/sessions",
+ actor: "slack:U-SUSPENDED",
+ });
+ const actorless = await signedFetch({
+ service: "slack-bot",
+ method: "GET",
+ url: "https://test.local/sessions",
+ });
+
+ expect(attributed.status).toBe(403);
+ await expect(attributed.json()).resolves.toMatchObject({ code: "active_user_required" });
+ expect(actorless.status).toBe(403);
+ await expect(actorless.json()).resolves.toMatchObject({ code: "service_actor_required" });
+ });
+
+ it("intersects an actor role with the service ceiling", async () => {
+ await signedFetch({
+ service: "slack-bot",
+ method: "GET",
+ url: "https://test.local/sessions",
+ actor: "slack:U-VIEWER",
+ });
+ const identity = await new UserStore(env.DB).getIdentity("slack", "U-VIEWER");
+ await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?")
+ .bind("role_builtin_viewer", identity!.userId)
+ .run();
+
+ const response = await signedFetch({
+ service: "slack-bot",
+ method: "POST",
+ url: "https://test.local/sessions",
+ actor: "slack:U-VIEWER",
+ body: JSON.stringify({ title: "Viewer session", model: "anthropic/claude-haiku-4-5" }),
+ });
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({
+ code: "permission_required",
+ permission: "sessions.create",
+ });
});
it("requires a user or signed actor before any service can create a session", async () => {
diff --git a/packages/linear-bot/src/webhook-handler.test.ts b/packages/linear-bot/src/webhook-handler.test.ts
index 2c792d905..e3ffb9d66 100644
--- a/packages/linear-bot/src/webhook-handler.test.ts
+++ b/packages/linear-bot/src/webhook-handler.test.ts
@@ -733,6 +733,9 @@ describe("handleAgentSessionEvent environment targets", () => {
const promptCall = controlPlaneFetch.mock.calls.find(([input]) =>
String(input).endsWith("/prompt")
);
+ const eventsCall = controlPlaneFetch.mock.calls.find(([input]) =>
+ String(input).includes("/events?")
+ );
const body = JSON.parse(String(promptCall?.[1]?.body)) as Record;
// Identity travels via the signed actor assertion, never the body.
expect(body).not.toHaveProperty("authorId");
@@ -750,6 +753,53 @@ describe("handleAgentSessionEvent environment targets", () => {
},
});
expect(body.callbackContext).not.toHaveProperty("transitionIssueOnStart");
+ expect(new Headers(eventsCall?.[1]?.headers).get("X-OpenInspect-Actor")).toBe(
+ "linear:follow-up-human-user"
+ );
+ expect(new Headers(promptCall?.[1]?.headers).get("X-OpenInspect-Actor")).toBe(
+ "linear:follow-up-human-user"
+ );
+ });
+
+ it("falls back to the session creator when follow-up author fields are absent", async () => {
+ const { kv } = createFakeKV({
+ "oauth:client-credentials:org-1": validToken(),
+ "issue:issue-1": JSON.stringify({
+ sessionId: "session-xyz",
+ issueId: "issue-1",
+ issueIdentifier: "ENG-42",
+ repoOwner: "acme",
+ repoName: "backend",
+ model: "anthropic/claude-haiku-4-5",
+ createdAt: Date.now(),
+ }),
+ });
+ const env = makeLinearBotEnv(kv);
+ const controlPlaneFetch = (env.CONTROL_PLANE as unknown as { fetch: ReturnType })
+ .fetch;
+ controlPlaneFetch.mockImplementation(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/integration-settings/")) return Response.json({ config: null });
+ if (url.endsWith("/events?type=token&limit=20")) return Response.json({ events: [] });
+ if (url.endsWith("/prompt")) return Response.json({ ok: true });
+ throw new Error(`Unexpected control-plane fetch to ${url}`);
+ });
+ const webhook = makeWebhook();
+ webhook.action = "prompted";
+ webhook.agentSession.creatorId = "session-creator";
+ webhook.agentActivity = {
+ content: { type: "prompt", body: "Please continue." },
+ };
+
+ await handleAgentSessionEvent(webhook, env, "trace-follow-up-creator-fallback");
+
+ const sessionCalls = controlPlaneFetch.mock.calls.filter(([input]) =>
+ /\/(events\?|prompt$)/.test(String(input))
+ );
+ expect(sessionCalls).toHaveLength(2);
+ for (const [, init] of sessionCalls) {
+ expect(new Headers(init?.headers).get("X-OpenInspect-Actor")).toBe("linear:session-creator");
+ }
});
it("adds prior token context from a parsed events response", async () => {
@@ -818,6 +868,10 @@ describe("handleAgentSessionEvent environment targets", () => {
"https://internal/sessions/session-xyz/stop",
expect.objectContaining({ method: "POST" })
);
+ const stopInit = controlPlaneFetch.mock.calls[0]?.[1] as RequestInit | undefined;
+ expect(new Headers(stopInit?.headers).get("X-OpenInspect-Actor")).toBe(
+ "linear:follow-up-human-user"
+ );
expect(store.has("issue:issue-1")).toBe(false);
});
diff --git a/packages/linear-bot/src/webhook-handler.ts b/packages/linear-bot/src/webhook-handler.ts
index 4d5a0b80f..d8468e81b 100644
--- a/packages/linear-bot/src/webhook-handler.ts
+++ b/packages/linear-bot/src/webhook-handler.ts
@@ -224,10 +224,16 @@ async function handleStop(webhook: AgentSessionWebhook, env: Env, traceId: strin
const existingSession = await lookupIssueSession(env, issueId);
if (existingSession) {
const stopUrl = `https://internal/sessions/${existingSession.sessionId}/stop`;
+ const actorUserId =
+ webhook.agentActivity?.userId ??
+ webhook.agentSession.comment?.userId ??
+ webhook.agentSession.creatorId ??
+ undefined;
try {
const stopRes = await signedControlPlaneFetch(env, {
method: "POST",
url: stopUrl,
+ actor: actorUserId ? `linear:${actorUserId}` : undefined,
traceId,
});
if (!stopRes.ok) {
@@ -309,12 +315,13 @@ function getFollowUp(webhook: AgentSessionWebhook): {
source: "linear_agent_activity" | "linear_comment" | "linear_fallback";
actorUserId?: string;
} {
+ const fallbackActorUserId = webhook.agentSession.creatorId ?? undefined;
const activityBody = webhook.agentActivity?.content?.body;
if (activityBody) {
return {
content: activityBody,
source: "linear_agent_activity",
- actorUserId: webhook.agentActivity?.userId,
+ actorUserId: webhook.agentActivity?.userId ?? fallbackActorUserId,
};
}
@@ -323,11 +330,15 @@ function getFollowUp(webhook: AgentSessionWebhook): {
return {
content: comment.body,
source: "linear_comment",
- actorUserId: comment.userId,
+ actorUserId: comment.userId ?? fallbackActorUserId,
};
}
- return { content: "Follow-up on the issue.", source: "linear_fallback" };
+ return {
+ content: "Follow-up on the issue.",
+ source: "linear_fallback",
+ actorUserId: fallbackActorUserId,
+ };
}
function buildLinearCallbackContext(params: {
@@ -419,6 +430,7 @@ async function handleFollowUp(
const eventsRes = await signedControlPlaneFetch(env, {
method: "GET",
url: eventsUrl,
+ actor: followUp.actorUserId ? `linear:${followUp.actorUserId}` : undefined,
traceId,
});
if (eventsRes.ok) {
diff --git a/packages/slack-bot/src/attachments.test.ts b/packages/slack-bot/src/attachments.test.ts
index 6ab59fb5b..207b43df9 100644
--- a/packages/slack-bot/src/attachments.test.ts
+++ b/packages/slack-bot/src/attachments.test.ts
@@ -56,7 +56,7 @@ function uploadCreatedResponse(attachmentId = "att-1"): Response {
/** Download + upload in one step, as the delivery pipeline runs them. */
async function prepareAndUpload(env: Env, sessionId: string, files: SlackMessageFile[]) {
const prepared = await prepareImageAttachments(env, toImageAttachments(files));
- return uploadPreparedAttachments(env, sessionId, prepared);
+ return uploadPreparedAttachments(env, sessionId, prepared, "slack:U1");
}
afterEach(() => {
@@ -270,7 +270,7 @@ describe("uploadPreparedAttachments", () => {
method: "POST",
url: uploadUrl,
bodySha256Hex: await sha256Hex(uploadInit.body as Uint8Array),
- actor: "",
+ actor: "slack:U1",
});
expect(verified).toMatchObject({ ok: true });
});
diff --git a/packages/slack-bot/src/attachments.ts b/packages/slack-bot/src/attachments.ts
index 72e82838e..71c4168fb 100644
--- a/packages/slack-bot/src/attachments.ts
+++ b/packages/slack-bot/src/attachments.ts
@@ -253,6 +253,7 @@ async function uploadToSession(
env: Env,
sessionId: string,
file: PreparedImageAttachments["files"][number],
+ authorId: string,
traceId?: string
): Promise<{ reference: SessionAttachmentReference } | { sessionMissing: boolean }> {
const { attachment, bytes } = file;
@@ -275,6 +276,7 @@ async function uploadToSession(
method: "POST",
url: `https://internal/sessions/${sessionId}/attachments`,
body: { bytes: multipartBytes, contentType },
+ actor: authorId.startsWith("slack:") ? authorId : undefined,
traceId,
},
{ signal: AbortSignal.timeout(OUTBOUND_REQUEST_TIMEOUT_MS) }
@@ -320,10 +322,11 @@ export async function uploadPreparedAttachments(
env: Env,
sessionId: string,
prepared: PreparedImageAttachments,
+ authorId: string,
traceId?: string
): Promise {
const outcomes = await Promise.all(
- prepared.files.map((file) => uploadToSession(env, sessionId, file, traceId))
+ prepared.files.map((file) => uploadToSession(env, sessionId, file, authorId, traceId))
);
const references: SessionAttachmentReference[] = [];
const dropped: SlackAttachmentDropReason[] = [...prepared.dropped];
diff --git a/packages/slack-bot/src/sessions/prompt-delivery.ts b/packages/slack-bot/src/sessions/prompt-delivery.ts
index edaa5fab1..5086e847b 100644
--- a/packages/slack-bot/src/sessions/prompt-delivery.ts
+++ b/packages/slack-bot/src/sessions/prompt-delivery.ts
@@ -60,7 +60,7 @@ export async function deliverPrompt(
threadTs,
traceId,
} = options;
- const upload = await uploadPreparedAttachments(env, sessionId, attachments, traceId);
+ const upload = await uploadPreparedAttachments(env, sessionId, attachments, authorId, traceId);
if (imageOnly && upload.references.length === 0) {
// The placeholder prompt would launch a meaningless run with nothing
From 68789f0c206d8094f6cf0fa4fc5544b233419e75 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Sun, 30 Aug 2026 21:51:07 -0700
Subject: [PATCH 3/9] feat: enforce session authorization and revoke stale
sockets
---
packages/control-plane/README.md | 4 +-
.../src/auth/identity-enforcement.ts | 12 +-
.../src/db/session-index.test.ts | 37 ----
.../control-plane/src/db/session-index.ts | 57 ++----
.../control-plane/src/router.policy.test.ts | 6 +-
.../src/routes/session-runtime-proxy.test.ts | 24 +--
.../src/routes/session-runtime-proxy.ts | 63 ++-----
.../src/session/authorization-lease.ts | 7 +
.../control-plane/src/session/components.ts | 45 +++--
.../src/session/connection-authenticator.ts | 88 +++++----
.../http/handlers/sandbox.handler.test.ts | 82 ---------
.../session/http/handlers/sandbox.handler.ts | 47 +----
.../session-lifecycle.handler.test.ts | 121 ++-----------
.../handlers/session-lifecycle.handler.ts | 69 +------
.../http/handlers/ws-token.handler.test.ts | 19 +-
.../session/http/handlers/ws-token.handler.ts | 7 +-
.../src/session/http/routes.test.ts | 2 -
.../control-plane/src/session/http/routes.ts | 7 +-
.../src/session/message-queue.test.ts | 1 +
.../src/session/participant-repository.ts | 8 +-
.../src/session/participant-service.test.ts | 2 +-
.../src/session/participant-service.ts | 2 -
.../src/session/presence-service.test.ts | 1 +
.../control-plane/src/session/schema.test.ts | 17 ++
packages/control-plane/src/session/schema.ts | 11 ++
.../src/session/websocket-manager.test.ts | 169 ++++++++++++++++--
.../src/session/websocket-manager.ts | 116 ++++++++++--
.../ws-client-mapping-repository.test.ts | 3 +-
.../session/ws-client-mapping-repository.ts | 35 +++-
packages/control-plane/src/types.ts | 4 +-
.../durable-object-eviction.test.ts | 5 +-
.../control-plane/test/integration/helpers.ts | 49 +++--
.../integration/session-lifecycle.test.ts | 4 +-
.../integration/session-repositories.test.ts | 31 ----
.../test/integration/websocket-client.test.ts | 95 +++++++++-
.../integration/ws-token-participants.test.ts | 50 ++----
packages/shared/src/types/sessions.ts | 9 -
37 files changed, 638 insertions(+), 671 deletions(-)
create mode 100644 packages/control-plane/src/session/authorization-lease.ts
diff --git a/packages/control-plane/README.md b/packages/control-plane/README.md
index a0b8b3cf2..0f8ef75da 100644
--- a/packages/control-plane/README.md
+++ b/packages/control-plane/README.md
@@ -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 |
@@ -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 |
diff --git a/packages/control-plane/src/auth/identity-enforcement.ts b/packages/control-plane/src/auth/identity-enforcement.ts
index 29db52541..8d492a223 100644
--- a/packages/control-plane/src/auth/identity-enforcement.ts
+++ b/packages/control-plane/src/auth/identity-enforcement.ts
@@ -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",
@@ -50,7 +45,6 @@ const FORBIDDEN_IDENTITY_FIELDS: Record = {
"session-create": SPAWNING_FORBIDDEN_FIELDS,
"ws-token": ["userId", "scmToken", "scmRefreshToken", "scmUserId"],
prompt: ["authorId"],
- "session-lifecycle": ["userId"],
"automation-create": SPAWNING_FORBIDDEN_FIELDS,
};
@@ -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. */
@@ -135,7 +129,7 @@ function isJsonObject(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
-export type IdentityEnforcement =
+type IdentityEnforcement =
| { rejection: Response; enforced?: undefined }
| { rejection?: undefined; enforced: EnforcedIdentity };
diff --git a/packages/control-plane/src/db/session-index.test.ts b/packages/control-plane/src/db/session-index.test.ts
index be9315607..0018b8e8d 100644
--- a/packages/control-plane/src/db/session-index.test.ts
+++ b/packages/control-plane/src/db/session-index.test.ts
@@ -750,43 +750,6 @@ describe("SessionIndexStore", () => {
]);
});
- it("trims and lowercases repo filters", async () => {
- await store.create(makeSession({ id: "match", repoOwner: "Owner", repoName: "Repo" }));
- await store.create(makeSession({ id: "other", repoOwner: "Other", repoName: "Repo" }));
-
- const result = await store.list({ repoOwner: " OWNER ", repoName: " REPO " });
-
- expect(result.sessions).toHaveLength(1);
- expect(result.sessions[0].id).toBe("match");
- });
-
- it("matches sessions through secondary members, not just the scalar primary", async () => {
- await store.create(
- makeSession({
- id: "multi",
- repoOwner: "acme",
- repoName: "frontend",
- repositories: [
- { repoOwner: "acme", repoName: "frontend", repoId: 1, baseBranch: "main" },
- { repoOwner: "acme", repoName: "backend", repoId: 2, baseBranch: "main" },
- ],
- })
- );
- await store.create(makeSession({ id: "other", repoOwner: "acme", repoName: "unrelated" }));
-
- const result = await store.list({ repoOwner: "acme", repoName: "backend" });
-
- expect(result.sessions.map((s) => s.id)).toEqual(["multi"]);
- });
-
- it("falls back to the scalar columns for pre-feature sessions without member rows", async () => {
- await store.create(makeSession({ id: "legacy", repoOwner: "acme", repoName: "app" }));
-
- const result = await store.list({ repoOwner: "acme", repoName: "app" });
-
- expect(result.sessions.map((s) => s.id)).toEqual(["legacy"]);
- });
-
it("supports multiple creator user ids", async () => {
await store.create(makeSession({ id: "alice", userId: "alice", updatedAt: 1000 }));
await store.create(makeSession({ id: "bob", userId: "bob", updatedAt: 3000 }));
diff --git a/packages/control-plane/src/db/session-index.ts b/packages/control-plane/src/db/session-index.ts
index 025b11fb3..b1d19f964 100644
--- a/packages/control-plane/src/db/session-index.ts
+++ b/packages/control-plane/src/db/session-index.ts
@@ -33,12 +33,6 @@ import { INACTIVE_SESSION_STATUS_SQL } from "@open-inspect/shared/types/session-
import { readStateFromRow, unreadSql, type ViewerReadStateRow } from "./session-read-state";
import type { SqlDatabase, SqlStatement } from "./sql-database";
-export type {
- ListSessionInboxOptions,
- ListSessionInboxResult,
- ListSessionInboxSnapshotResult,
-} from "./session-inbox-store";
-
const CHILD_ADMISSION_LEASE_TTL_MS = 5 * 60 * 1000;
export interface ChildAdmissionLease {
@@ -60,8 +54,9 @@ const MAX_DESCENDANT_DEPTH = 10;
* primary, mirrored into the scalar repo_owner/repo_name columns). Aliases
* the shared wire type so Session.repositories and this share one shape.
*/
-export type SessionIndexRepository = SessionListRepository;
+type SessionIndexRepository = SessionListRepository;
+/** Persisted session metadata with optional viewer-specific read state. */
export interface SessionEntry {
id: string;
title: string | null;
@@ -142,24 +137,24 @@ interface SessionModelProviderAuthRow {
inherited_from_session_id: string | null;
}
+/** Filters, pagination, and viewer read state for a session list query. */
export interface ListSessionsOptions {
status?: SessionStatus;
excludeStatus?: SessionStatus;
excludeAutomationLineage?: boolean;
- repoOwner?: string;
- repoName?: string;
createdByUserIds?: readonly string[];
limit?: number;
offset?: number;
viewerUserId?: string;
}
+/** Paginated session index entries. */
export interface ListSessionsResult {
sessions: SessionEntry[];
hasMore: boolean;
}
-interface ViewerSessionRow extends SessionRow, ViewerReadStateRow {}
+type ViewerSessionRow = SessionRow & ViewerReadStateRow;
function toEntry(row: SessionRow): SessionEntry {
return {
@@ -236,6 +231,7 @@ function normalizeSessionRepositoryFields(session: SessionEntry): {
};
}
+/** D1-backed session index and viewer-specific list projection. */
export class SessionIndexStore {
constructor(private readonly db: SqlDatabase) {}
@@ -507,13 +503,12 @@ export class SessionIndexStore {
return row !== null;
}
+ /** List sessions with optional viewer-specific read state. */
async list(options: ListSessionsOptions = {}): Promise {
const {
status,
excludeStatus,
excludeAutomationLineage,
- repoOwner,
- repoName,
createdByUserIds,
limit = DEFAULT_SESSION_LIST_LIMIT,
offset = DEFAULT_SESSION_LIST_OFFSET,
@@ -541,39 +536,14 @@ export class SessionIndexStore {
conditions.push("automation_id IS NULL AND spawn_source NOT IN ('automation', 'github-bot')");
}
- // Repo filters match against the membership table so a session is found
- // through ANY member, not just the scalar primary mirror. The scalar arm
- // is the fallback for pre-feature sessions without member rows.
- const normalizedRepoOwner = normalizeRepoIdentifier(repoOwner);
- const normalizedRepoName = normalizeRepoIdentifier(repoName);
- if (normalizedRepoOwner || normalizedRepoName) {
- const memberConditions: string[] = [];
- const scalarConditions: string[] = [];
- const repoFilterParams: unknown[] = [];
- if (normalizedRepoOwner) {
- memberConditions.push("sr.repo_owner = ?");
- scalarConditions.push("repo_owner = ?");
- repoFilterParams.push(normalizedRepoOwner);
- }
- if (normalizedRepoName) {
- memberConditions.push("sr.repo_name = ?");
- scalarConditions.push("repo_name = ?");
- repoFilterParams.push(normalizedRepoName);
- }
- conditions.push(
- `(EXISTS (SELECT 1 FROM session_repositories sr WHERE sr.session_id = sessions.id AND ${memberConditions.join(" AND ")}) OR (${scalarConditions.join(" AND ")}))`
- );
- params.push(...repoFilterParams, ...repoFilterParams);
- }
-
if (createdByUserIds?.length) {
conditions.push(`user_id IN (${createdByUserIds.map(() => "?").join(", ")})`);
params.push(...createdByUserIds);
}
-
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const pageSql = `SELECT * FROM sessions ${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`;
+ const pageParams = [...params, limit + 1, offset];
const result = viewerUserId
? await this.db
.prepare(
@@ -587,11 +557,11 @@ export class SessionIndexStore {
AND read_state.user_id = viewer.id
ORDER BY paged_sessions.updated_at DESC`
)
- .bind(...params, limit + 1, offset, viewerUserId)
+ .bind(...pageParams, viewerUserId)
.all()
: await this.db
.prepare(pageSql)
- .bind(...params, limit + 1, offset)
+ .bind(...pageParams)
.all();
const rows = result.results || [];
@@ -608,10 +578,12 @@ export class SessionIndexStore {
};
}
+ /** List one inbox category with viewer-specific read state. */
async listInbox(options: ListSessionInboxOptions): Promise {
return new SessionInboxStore(this.db).list(options);
}
+ /** List the first page of every inbox category with viewer-specific read state. */
async listInboxSnapshot(
options: Omit
): Promise {
@@ -657,11 +629,6 @@ export class SessionIndexStore {
return (result.meta.changes ?? 0) > 0;
}
- /** Current single-tenant visibility boundary; future grants belong here. */
- async getVisibleForUser(sessionId: string, _userId: string): Promise {
- return this.get(sessionId);
- }
-
async updateReadState(
userId: string,
sessionId: string,
diff --git a/packages/control-plane/src/router.policy.test.ts b/packages/control-plane/src/router.policy.test.ts
index 56764d958..242fb86d9 100644
--- a/packages/control-plane/src/router.policy.test.ts
+++ b/packages/control-plane/src/router.policy.test.ts
@@ -135,11 +135,7 @@ describe("route policy table", () => {
expect(routeFor("GET", "/sessions/session-1/media/artifact-1")?.authorization).toMatchObject({
service: { kind: "actor", actorlessGrants: [{ service: "slack-bot" }] },
});
- expect(routeFor("POST", "/sessions/session-1/participants")?.authorization).toEqual({
- kind: "active-user",
- allOf: [{ kind: "permission", permission: "sessions.collaborate" }],
- service: { kind: "actor" },
- });
+ expect(routeFor("POST", "/sessions/session-1/participants")).toBeUndefined();
expect(routeFor("POST", "/sessions/parent/children")?.authorization).toMatchObject({
kind: "active-user",
allOf: [
diff --git a/packages/control-plane/src/routes/session-runtime-proxy.test.ts b/packages/control-plane/src/routes/session-runtime-proxy.test.ts
index 7004b796f..62688ce26 100644
--- a/packages/control-plane/src/routes/session-runtime-proxy.test.ts
+++ b/packages/control-plane/src/routes/session-runtime-proxy.test.ts
@@ -342,12 +342,11 @@ describe("session runtime proxy routes", () => {
expect(requests[0].method).toBe("POST");
expect(new URL(requests[0].url).pathname).toBe(SessionInternalPaths.updateTitle);
await expect(requests[0].json()).resolves.toEqual({
- userId: "user-1",
title: "New title",
});
});
- it("forwards the verified service actor on title updates", async () => {
+ it("does not forward service actor identity on title updates", async () => {
const requests: Request[] = [];
const fetch = vi.fn(async (request: Request) => {
requests.push(request);
@@ -380,7 +379,6 @@ describe("session runtime proxy routes", () => {
expect(response.status).toBe(200);
expect(fetch).toHaveBeenCalledOnce();
await expect(requests[0].json()).resolves.toEqual({
- userId: "slack:U0123",
title: "New title",
});
});
@@ -437,26 +435,6 @@ describe("session runtime proxy routes", () => {
await expect(response.json()).resolves.toEqual({ error: "Session not found" });
});
- it("rejects malformed add-participant JSON without forwarding to the runtime", async () => {
- const fetch = vi.fn(async () => Response.json({ status: "ok" }));
- const { handler, match } = getHandler("POST", "/sessions/session-1/participants");
-
- const response = await handler(
- new Request("https://test.local/sessions/session-1/participants", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: "{",
- }),
- createEnv(fetch),
- match,
- createCtx()
- );
-
- expect(response.status).toBe(400);
- await expect(response.json()).resolves.toEqual({ error: "Invalid JSON body" });
- expect(fetch).not.toHaveBeenCalled();
- });
-
it("forwards the draft flag through the create-PR contract", async () => {
const requests: Request[] = [];
const fetch = vi.fn(async (request: Request) => {
diff --git a/packages/control-plane/src/routes/session-runtime-proxy.ts b/packages/control-plane/src/routes/session-runtime-proxy.ts
index 01144fb8b..c6838a29a 100644
--- a/packages/control-plane/src/routes/session-runtime-proxy.ts
+++ b/packages/control-plane/src/routes/session-runtime-proxy.ts
@@ -1,4 +1,3 @@
-import { applyIdentityEnforcement } from "../auth/identity-enforcement";
import { readBodyCapped } from "@open-inspect/shared/http-body";
import type {
SessionParticipantProfilesResponse,
@@ -117,25 +116,6 @@ function legacyTokenRefreshRoute(
);
}
-async function handleAddParticipant(
- request: Request,
- _env: Env,
- match: RegExpMatchArray,
- ctx: SessionRouteContext
-): Promise {
- const sessionId = getSessionId(match);
- if (sessionId instanceof Response) return sessionId;
-
- const body = await parseJsonBody(request);
- if (body instanceof Response) return body;
-
- return ctx.sessionRuntime.fetch(sessionId, SessionInternalPaths.participants, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
-}
-
async function handleSandboxError(
request: Request,
_env: Env,
@@ -258,23 +238,23 @@ async function handleCreatePR(
});
}
-/** Read a lifecycle body under verified identity enforcement. */
-async function readEnforcedLifecycleBody(
- request: Request,
- ctx: SessionRouteContext
-): Promise<{ userId?: string; title?: string; rejection?: Response }> {
+/**
+ * Title updates accept a bodyless request but reject caller-supplied identity.
+ */
+async function readTitleBody(request: Request): Promise<{ title?: string; rejection?: Response }> {
let body: { title?: string } = {};
try {
const parsed: unknown = await request.json();
- if (isObjectBody(parsed)) body = parsed;
+ if (isObjectBody(parsed)) {
+ if ("userId" in parsed) {
+ return { rejection: error("Field 'userId' is not accepted from verified callers", 400) };
+ }
+ body = parsed;
+ }
} catch {
// Body parsing failed, continue without fields.
}
-
- const enforcement = applyIdentityEnforcement(ctx, "session-lifecycle", body);
- if (enforcement.rejection) return { rejection: enforcement.rejection };
-
- return { userId: enforcement.enforced.participantUserId ?? undefined, title: body.title };
+ return { title: body.title };
}
function lifecycleProxyRoute(
@@ -292,15 +272,17 @@ function lifecycleProxyRoute(
const sessionId = getSessionId(match);
if (sessionId instanceof Response) return sessionId;
- const { userId, title, rejection } = await readEnforcedLifecycleBody(request, ctx);
- if (rejection) return rejection;
+ let body = {};
+ if (internalPath === SessionInternalPaths.updateTitle) {
+ const { title, rejection } = await readTitleBody(request);
+ if (rejection) return rejection;
+ body = { title };
+ }
return ctx.sessionRuntime.fetch(sessionId, internalPath, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify(
- internalPath === SessionInternalPaths.updateTitle ? { userId, title } : { userId }
- ),
+ body: JSON.stringify(body),
});
},
})
@@ -373,15 +355,6 @@ export const sessionRuntimeProxyRoutes: Route[] = [
handler: handleParticipantProfiles,
})
),
- defineRoute(
- GITHUB_USER_OR_SERVICE_ROUTE,
- sessionRoute({
- method: "POST",
- pattern: parsePattern("/sessions/:id/participants"),
- authorization: requirePermission("sessions.collaborate"),
- handler: handleAddParticipant,
- })
- ),
simpleProxyRoute({
policy: GITHUB_USER_OR_SERVICE_ROUTE,
method: "GET",
diff --git a/packages/control-plane/src/session/authorization-lease.ts b/packages/control-plane/src/session/authorization-lease.ts
new file mode 100644
index 000000000..799c4da62
--- /dev/null
+++ b/packages/control-plane/src/session/authorization-lease.ts
@@ -0,0 +1,7 @@
+/** Strict wall-clock bound for browser WebSocket authorization. */
+export const WS_AUTHORIZATION_LEASE_MS = 5 * 60 * 1000;
+
+/** Signals that the browser must discard its credential and reconnect fresh. */
+export const WS_CLOSE_AUTHORIZATION_REVOKED = 4010;
+
+export const WS_AUTHORIZATION_REVOKED_REASON = "Authorization expired or changed";
diff --git a/packages/control-plane/src/session/components.ts b/packages/control-plane/src/session/components.ts
index b9f976025..6beb5c68c 100644
--- a/packages/control-plane/src/session/components.ts
+++ b/packages/control-plane/src/session/components.ts
@@ -127,6 +127,7 @@ import { SessionMessengerImpl, type SessionMessenger } from "./messenger";
import { SessionStatusService } from "./session-status-service";
import { SessionTitleService } from "./title-service";
import { parseArtifactMetadata } from "./artifact-metadata";
+import { AuthorizationError, AuthorizationService } from "../authorization/service";
/**
* Timeout for WebSocket authentication (in milliseconds).
@@ -153,7 +154,7 @@ export interface SessionRuntime {
readonly log: Logger;
readonly server: SessionServer;
readonly alarms: {
- /** Re-arm any persisted alarm deadline after a cold start. */
+ /** Expire stale authorization leases and re-arm persisted deadlines after a cold start. */
rehydrate(): void;
};
readonly internals: SessionComponents;
@@ -207,6 +208,7 @@ function resolveExecutionTimeoutMs(
return parseInt(env.EXECUTION_TIMEOUT_MS || String(DEFAULT_SANDBOX_TIMEOUT_SECONDS * 1000), 10);
}
+/** Build the session runtime, including authorization verification and lease expiry handling. */
export function createSessionRuntime(platform: SessionPlatform, env: Env): SessionRuntime {
const { ctx, sql, db } = platform;
const durableObjectId = ctx.id.toString();
@@ -252,14 +254,15 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi
const sandboxRepository = new SandboxRepository(sql, log, repoSecretsEncryptionKey);
// Tier 2 — sockets and alarm scheduling.
+ const alarmScheduler = createEarliestAlarmScheduler(ctx.storage, alarmDeadlines);
const wsManager: SessionWebSocketManager = new SessionWebSocketManagerImpl(
ctx,
sandboxRepository,
wsClientMappingRepository,
+ alarmScheduler,
log,
{ authTimeoutMs: WS_AUTH_TIMEOUT_MS }
);
- const alarmScheduler = createEarliestAlarmScheduler(ctx.storage, alarmDeadlines);
// Hibernation-level ping/pong: the runtime answers keepalives without
// waking the Durable Object. Platform-global wiring, so it lives here.
ctx.setWebSocketAutoResponse(
@@ -571,7 +574,6 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi
const sandboxHandler = new SandboxHandler(
messageRepository,
eventRepository,
- participantRepository,
artifactRepository,
sessionCoreRepository,
sandboxRepository,
@@ -607,7 +609,6 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi
sessionCoreRepository,
sandboxRepository,
messageRepository,
- participantRepository,
statusService,
titleService,
lifecycleWsManager,
@@ -685,6 +686,20 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi
snapshotReader,
schedulePullRequestRefresh,
scmProviderName,
+ verifyAuthorization: async (userId) => {
+ if (!db) return "unavailable";
+ try {
+ await new AuthorizationService(db).requirePermission(userId, "sessions.collaborate");
+ return "valid";
+ } catch (error) {
+ if (error instanceof AuthorizationError) return "rejected";
+ log.error("WebSocket authorization verification failed", {
+ user_id: userId,
+ error: error instanceof Error ? error : String(error),
+ });
+ return "unavailable";
+ }
+ },
log,
});
@@ -708,7 +723,6 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi
);
},
listParticipants: () => participantsHandler.listParticipants(),
- addParticipant: (request) => sandboxHandler.addParticipant(request),
listEvents: (_request, url) => messagesHandler.listEvents(url),
listArtifacts: (_request, url) => messagesHandler.listArtifacts(url),
listMessages: (_request, url) => messagesHandler.listMessages(url),
@@ -718,8 +732,8 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi
pullRequestsRefresh: () => pullRequestHandler.refreshPullRequests(),
wsToken: (request, _url, requestLog) => wsTokenHandler.generateWsToken(request, requestLog),
updateTitle: (request) => sessionLifecycleHandler.updateTitle(request),
- archive: (request) => sessionLifecycleHandler.archive(request),
- unarchive: (request) => sessionLifecycleHandler.unarchive(request),
+ archive: () => sessionLifecycleHandler.archive(),
+ unarchive: () => sessionLifecycleHandler.unarchive(),
expireDraft: () => sessionLifecycleHandler.expireDraft(),
verifySandboxToken: (request, _url, requestLog) =>
sandboxHandler.verifySandboxToken(request, requestLog),
@@ -796,7 +810,10 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi
handleScheduledDeadline: () =>
handleAlarmDelivery(
alarmDeadlines,
- () => alarmHandler.handle(),
+ async () => {
+ await wsManager.expireAuthorizationLeases(Date.now());
+ await alarmHandler.handle();
+ },
() => alarmScheduler.rearmPending()
),
});
@@ -825,9 +842,15 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi
server,
alarms: {
rehydrate: () =>
- backgroundTasks.submit(() => alarmScheduler.rehydrate(), {
- name: "alarm.rehydrate",
- }),
+ backgroundTasks.submit(
+ async () => {
+ await wsManager.expireAuthorizationLeases(Date.now());
+ await alarmScheduler.rehydrate();
+ },
+ {
+ name: "alarm.rehydrate",
+ }
+ ),
},
internals: components,
};
diff --git a/packages/control-plane/src/session/connection-authenticator.ts b/packages/control-plane/src/session/connection-authenticator.ts
index e66e6a1f7..aa91bdfb8 100644
--- a/packages/control-plane/src/session/connection-authenticator.ts
+++ b/packages/control-plane/src/session/connection-authenticator.ts
@@ -17,6 +17,10 @@ import type { SandboxRepository } from "./sandbox-repository";
import type { SessionCoreRepository } from "./session-core-repository";
import type { SessionSnapshotReader } from "./snapshot-reader";
import type { SessionWebSocketManager } from "./websocket-manager";
+import {
+ WS_AUTHORIZATION_REVOKED_REASON,
+ WS_CLOSE_AUTHORIZATION_REVOKED,
+} from "./authorization-lease";
/**
* Maximum age of a WebSocket authentication token (in milliseconds).
@@ -25,6 +29,7 @@ import type { SessionWebSocketManager } from "./websocket-manager";
*/
const WS_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
+/** Dependencies for authenticating sockets and validating browser authorization. */
export interface SessionConnectionAuthenticatorDeps {
wsManager: SessionWebSocketManager;
sessionCoreRepository: SessionCoreRepository;
@@ -38,6 +43,8 @@ export interface SessionConnectionAuthenticatorDeps {
snapshotReader: SessionSnapshotReader;
schedulePullRequestRefresh: (trigger: "open" | "manual") => void;
scmProviderName: SourceControlProviderName;
+ /** Revalidate a user's session-collaboration permission before granting a lease. */
+ verifyAuthorization: (userId: string) => Promise<"valid" | "rejected" | "unavailable">;
/** The session-scoped logger; upgrade/subscribe paths also receive request-scoped children. */
log: Logger;
}
@@ -45,8 +52,8 @@ export interface SessionConnectionAuthenticatorDeps {
/**
* Admits connections to the session: sandbox WebSocket upgrades (token +
* lifecycle-state guards, re-checked after the non-storage token-hash await),
- * client subscriptions (token TTL, snapshot handoff), and post-hibernation
- * client identity recovery.
+ * client subscriptions (token TTL, permission checks, authorization leases,
+ * snapshot handoff), and post-hibernation client identity recovery.
*/
export class SessionConnectionAuthenticator {
constructor(private readonly deps: SessionConnectionAuthenticatorDeps) {}
@@ -210,9 +217,7 @@ export class SessionConnectionAuthenticator {
}
}
- /**
- * Handle client subscription with token validation.
- */
+ /** Validate the client token and current permission before granting an authorization lease. */
async handleSubscribe(
ws: WebSocket,
data: {
@@ -255,6 +260,26 @@ export class SessionConnectionAuthenticator {
return;
}
+ if (!participant.canonical_user_id) {
+ wsManager.close(ws, WS_CLOSE_AUTHORIZATION_REVOKED, WS_AUTHORIZATION_REVOKED_REASON);
+ return;
+ }
+
+ const authorization = await this.deps.verifyAuthorization(participant.canonical_user_id);
+ if (authorization !== "valid") {
+ log.warn("ws.connect", {
+ event: "ws.connect",
+ ws_type: "client",
+ outcome: "auth_failed",
+ reject_reason:
+ authorization === "unavailable" ? "authorization_unavailable" : "authorization_denied",
+ participant_id: participant.id,
+ user_id: participant.canonical_user_id,
+ });
+ wsManager.close(ws, WS_CLOSE_AUTHORIZATION_REVOKED, WS_AUTHORIZATION_REVOKED_REASON);
+ return;
+ }
+
// Reject tokens older than the TTL
if (
participant.ws_token_created_at === null ||
@@ -272,16 +297,8 @@ export class SessionConnectionAuthenticator {
return;
}
- log.info("ws.connect", {
- event: "ws.connect",
- ws_type: "client",
- outcome: "success",
- participant_id: participant.id,
- user_id: participant.user_id,
- client_id: data.clientId,
- });
-
- // Build client info from participant data
+ const enrichment = await this.deps.snapshotReader.resolveSessionSnapshotEnrichment();
+ const authorizationExpiresAt = await wsManager.grantLease(ws, participant.id, data.clientId);
const clientInfo: ClientInfo = {
participantId: participant.id,
userId: participant.canonical_user_id ?? participant.user_id,
@@ -290,15 +307,22 @@ export class SessionConnectionAuthenticator {
status: "active",
lastSeen: Date.now(),
clientId: data.clientId,
+ authorizationExpiresAt,
ws,
};
- const enrichment = await this.deps.snapshotReader.resolveSessionSnapshotEnrichment();
if (!this.completeClientSubscription(ws, clientInfo, enrichment)) {
wsManager.close(ws, 4009, "Session synchronization failed");
return;
}
-
+ log.info("ws.connect", {
+ event: "ws.connect",
+ ws_type: "client",
+ outcome: "success",
+ participant_id: participant.id,
+ user_id: participant.user_id,
+ client_id: data.clientId,
+ });
presenceService.sendPresence(ws);
presenceService.broadcastPresence();
this.deps.schedulePullRequestRefresh("open");
@@ -317,7 +341,7 @@ export class SessionConnectionAuthenticator {
client: ClientInfo,
enrichment: Parameters[0]
): boolean {
- const { wsManager, snapshotReader, log } = this.deps;
+ const { wsManager, snapshotReader } = this.deps;
const snapshot = snapshotReader.readSessionSnapshot(enrichment);
if (!snapshot) return false;
@@ -338,35 +362,21 @@ export class SessionConnectionAuthenticator {
}
wsManager.setClient(ws, client);
- const parsed = wsManager.classify(ws);
- if (parsed.kind === "client" && parsed.wsId) {
- wsManager.persistClientMapping(parsed.wsId, client.participantId, client.clientId);
- log.debug("Stored ws_client_mapping", {
- ws_id: parsed.wsId,
- participant_id: client.participantId,
- });
- }
return true;
}
- /**
- * Get client info for a WebSocket, reconstructing from storage if needed after hibernation.
- */
+ /** Return authorized client state, recovering an unexpired lease after hibernation. */
getClientInfo(ws: WebSocket): ClientInfo | null {
const { wsManager, log } = this.deps;
- // 1. In-memory cache (manager)
- const cached = wsManager.getClient(ws);
- if (cached) return cached;
-
- // 2. DB recovery (manager handles tag parsing + DB lookup)
- const mapping = wsManager.recoverClientMapping(ws);
- if (!mapping) {
+ const lookup = wsManager.lookupClient(ws);
+ if (lookup.kind === "cached") return lookup.client;
+ if (lookup.kind === "authorization_rejected") return null;
+ if (lookup.kind === "missing") {
log.warn("No client mapping found after hibernation, closing WebSocket");
wsManager.close(ws, 4002, "Session expired, please reconnect");
return null;
}
-
- // 3. Build ClientInfo
+ const { mapping } = lookup;
log.info("Recovered client info from DB", { user_id: mapping.user_id });
const clientInfo: ClientInfo = {
participantId: mapping.participant_id,
@@ -376,10 +386,10 @@ export class SessionConnectionAuthenticator {
status: "active",
lastSeen: Date.now(),
clientId: mapping.client_id || `client-${Date.now()}`,
+ authorizationExpiresAt: mapping.authorization_expires_at,
ws,
};
- // 4. Re-cache
wsManager.setClient(ws, clientInfo);
return clientInfo;
}
diff --git a/packages/control-plane/src/session/http/handlers/sandbox.handler.test.ts b/packages/control-plane/src/session/http/handlers/sandbox.handler.test.ts
index 07bd374bd..35488b95d 100644
--- a/packages/control-plane/src/session/http/handlers/sandbox.handler.test.ts
+++ b/packages/control-plane/src/session/http/handlers/sandbox.handler.test.ts
@@ -9,7 +9,6 @@ import {
import type { SandboxRow, SessionRow } from "../../types";
import { SandboxHandler } from "./sandbox.handler";
import type { ArtifactRepository } from "../../artifact-repository";
-import type { ParticipantRepository } from "../../participant-repository";
import type { EventRepository } from "../../event-repository";
import type { MessageRepository } from "../../message-repository";
import type { SessionCoreRepository } from "../../session-core-repository";
@@ -18,7 +17,6 @@ import type { SessionSandboxEventProcessor } from "../../sandbox-events/processo
function createHandler({ managedSecretsConfigured = true } = {}) {
const repository = {
- createParticipant: vi.fn(),
createEvent: vi.fn(),
getProcessingMessage: vi.fn(),
};
@@ -47,7 +45,6 @@ function createHandler({ managedSecretsConfigured = true } = {}) {
const sandboxHandler = new SandboxHandler(
repository as unknown as MessageRepository,
repository as unknown as EventRepository,
- repository as unknown as ParticipantRepository,
artifactRepository,
{ getSession } as unknown as SessionCoreRepository,
{ getSandbox } as unknown as SandboxRepository,
@@ -69,7 +66,6 @@ function createHandler({ managedSecretsConfigured = true } = {}) {
sandboxEvent: (request: Request) => sandboxHandler.sandboxEvent(request),
sandboxError: (request: Request) => sandboxHandler.sandboxError(request),
createMediaArtifact: (request: Request) => sandboxHandler.createMediaArtifact(request),
- addParticipant: (request: Request) => sandboxHandler.addParticipant(request),
verifySandboxToken: (request: Request) => sandboxHandler.verifySandboxToken(request, log),
openaiTokenRefresh: () => sandboxHandler.openaiTokenRefresh(log),
xaiTokenRefresh: () => sandboxHandler.xaiTokenRefresh(log),
@@ -265,84 +261,6 @@ describe("SandboxHandler", () => {
expect(processSandboxEvent).not.toHaveBeenCalled();
});
- it("adds participant with defaults and returns id", async () => {
- const { handler, repository, generateId, now } = createHandler();
-
- const response = await handler.addParticipant(
- new Request("http://internal/internal/participants", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- userId: "user-1",
- scmLogin: "octocat",
- scmName: "The Octocat",
- }),
- })
- );
-
- expect(response.status).toBe(200);
- expect(await response.json()).toEqual({ id: "participant-1", status: "added" });
- expect(generateId).toHaveBeenCalled();
- expect(now).toHaveBeenCalled();
- expect(repository.createParticipant).toHaveBeenCalledWith({
- id: "participant-1",
- userId: "user-1",
- scmLogin: "octocat",
- scmName: "The Octocat",
- scmEmail: null,
- role: "member",
- joinedAt: 1234,
- });
- });
-
- it("adds participant with a parsed owner role", async () => {
- const { handler, repository } = createHandler();
-
- const response = await handler.addParticipant(
- new Request("http://internal/internal/participants", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ userId: "user-1", role: "owner" }),
- })
- );
-
- expect(response.status).toBe(200);
- expect(repository.createParticipant).toHaveBeenCalledWith(
- expect.objectContaining({ userId: "user-1", role: "owner" })
- );
- });
-
- it("rejects malformed participant bodies", async () => {
- const { handler, repository } = createHandler();
-
- const response = await handler.addParticipant(
- new Request("http://internal/internal/participants", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ userId: 123 }),
- })
- );
-
- expect(response.status).toBe(400);
- expect(await response.json()).toEqual({ error: "Invalid participant body" });
- expect(repository.createParticipant).not.toHaveBeenCalled();
- });
-
- it("rejects invalid participant roles", async () => {
- const { handler, repository } = createHandler();
-
- const response = await handler.addParticipant(
- new Request("http://internal/internal/participants", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ userId: "user-1", role: "admin" }),
- })
- );
-
- expect(response.status).toBe(400);
- expect(repository.createParticipant).not.toHaveBeenCalled();
- });
-
it("creates a media artifact row and matching timeline event", async () => {
const { handler, getSandbox, repository, artifactRepository, broadcast, generateId } =
createHandler();
diff --git a/packages/control-plane/src/session/http/handlers/sandbox.handler.ts b/packages/control-plane/src/session/http/handlers/sandbox.handler.ts
index 332346098..3572570b8 100644
--- a/packages/control-plane/src/session/http/handlers/sandbox.handler.ts
+++ b/packages/control-plane/src/session/http/handlers/sandbox.handler.ts
@@ -5,7 +5,6 @@ import {
} from "@open-inspect/shared/types/session-api";
import type { SessionArtifact } from "@open-inspect/shared/types/artifacts";
import { sandboxEventSchema, type SandboxEvent } from "@open-inspect/shared/types/sandbox-events";
-import type { ParticipantRole } from "@open-inspect/shared/types/sessions";
import { isDeadSandboxStatus } from "../../../sandbox/lifecycle/decisions";
import {
OpenAITokenNotConfiguredError,
@@ -20,7 +19,6 @@ import type { SessionMessenger } from "../../messenger";
import type { MessageRepository } from "../../message-repository";
import type { ArtifactRepository } from "../../artifact-repository";
import type { EventRepository } from "../../event-repository";
-import type { ParticipantRepository } from "../../participant-repository";
import type { SessionCoreRepository } from "../../session-core-repository";
import type { SandboxRepository } from "../../sandbox-repository";
import type { SessionSandboxEventProcessor } from "../../sandbox-events/processor";
@@ -29,30 +27,20 @@ import { assertArtifactType } from "../../artifacts";
import { parseTunnelUrls } from "../../tunnel-urls";
import { z } from "zod";
-const addParticipantRequestSchema = z.object({
- userId: z.string(),
- scmLogin: z.string().optional(),
- scmName: z.string().optional(),
- scmEmail: z.string().optional(),
- role: z.enum(["owner", "member"] satisfies [ParticipantRole, ParticipantRole]).optional(),
-});
-
const sandboxErrorRequestSchema = z.object({
error: z.string().trim().min(1).max(1000),
});
-type AddParticipantRequest = z.infer;
-
/**
* HTTP boundary for the sandbox-facing endpoints: event ingestion, media
- * artifacts, participant registration, token verification, and the
+ * artifacts, token verification, and the
* credential/token refresh routes the in-sandbox tooling calls.
*/
export class SandboxHandler {
+ /** Create the sandbox HTTP handler with its repositories and service dependencies. */
constructor(
private readonly messageRepository: MessageRepository,
private readonly eventRepository: EventRepository,
- private readonly participantRepository: ParticipantRepository,
private readonly artifactRepository: ArtifactRepository,
private readonly sessionCoreRepository: SessionCoreRepository,
private readonly sandboxRepository: SandboxRepository,
@@ -209,37 +197,6 @@ export class SandboxHandler {
return Response.json({ status: "ok", artifactId: artifact.id });
}
- async addParticipant(request: Request): Promise {
- let raw: unknown;
- try {
- raw = await request.json();
- } catch {
- return Response.json({ error: "Invalid request body" }, { status: 400 });
- }
-
- const result = addParticipantRequestSchema.safeParse(raw);
- if (!result.success) {
- return Response.json({ error: "Invalid participant body" }, { status: 400 });
- }
-
- const body: AddParticipantRequest = result.data;
-
- const id = this.generateId();
- const now = this.now();
-
- this.participantRepository.createParticipant({
- id,
- userId: body.userId,
- scmLogin: body.scmLogin ?? null,
- scmName: body.scmName ?? null,
- scmEmail: body.scmEmail ?? null,
- role: body.role ?? "member",
- joinedAt: now,
- });
-
- return Response.json({ id, status: "added" });
- }
-
async verifySandboxToken(request: Request, log: Logger): Promise {
let raw: unknown;
try {
diff --git a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts
index b445de67c..34a69575c 100644
--- a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts
+++ b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.test.ts
@@ -1,10 +1,9 @@
import { describe, expect, it, vi } from "vitest";
-import type { ParticipantRow, SandboxRow, SessionRow } from "../../types";
+import type { SandboxRow, SessionRow } from "../../types";
import { SessionLifecycleHandler } from "./session-lifecycle.handler";
import type { SessionTitleService } from "../../title-service";
import type { WebSocketManager } from "../../../sandbox/lifecycle/manager";
import type { SessionStatusService } from "../../session-status-service";
-import type { ParticipantRepository } from "../../participant-repository";
import type { MessageRepository } from "../../message-repository";
import type { SandboxRepository } from "../../sandbox-repository";
import type { SessionCoreRepository } from "../../session-core-repository";
@@ -68,34 +67,12 @@ function createSandbox(overrides: Partial = {}): SandboxRow {
};
}
-function createParticipant(overrides: Partial = {}): ParticipantRow {
- return {
- id: "participant-1",
- user_id: "user-1",
- scm_user_id: null,
- scm_login: "octocat",
- scm_email: "octocat@example.com",
- scm_name: "The Octocat",
- auth_name: null,
- role: "member",
- scm_access_token_encrypted: null,
- scm_refresh_token_encrypted: null,
- scm_token_expires_at: null,
- ws_auth_token: null,
- ws_token_created_at: null,
- joined_at: 1,
- ...overrides,
- };
-}
-
function createHandler() {
const getSession = vi.fn<() => SessionRow | null>();
- const getParticipantByUserId = vi.fn<(userId: string) => ParticipantRow | null>();
const repository = {
getPendingOrProcessingCount: vi.fn(() => 0),
getMessageCount: vi.fn(() => 0),
getSession,
- getParticipantByUserId,
};
const getSandbox = vi.fn<() => SandboxRow | null>();
const updateSandboxStatus = vi.fn();
@@ -120,7 +97,6 @@ function createHandler() {
repository as unknown as SessionCoreRepository,
sandboxRepository,
repository as unknown as MessageRepository,
- repository as unknown as ParticipantRepository,
statusService,
{ applySessionTitleUpdate } as unknown as SessionTitleService,
{
@@ -136,8 +112,8 @@ function createHandler() {
const handler = {
getState: () => lifecycleHandler.getState(),
updateTitle: (request: Request) => lifecycleHandler.updateTitle(request),
- archive: (request: Request) => lifecycleHandler.archive(request),
- unarchive: (request: Request) => lifecycleHandler.unarchive(request),
+ archive: (_request?: Request) => lifecycleHandler.archive(),
+ unarchive: (_request?: Request) => lifecycleHandler.unarchive(),
expireDraft: () => lifecycleHandler.expireDraft(),
cancel: () => lifecycleHandler.cancel(),
};
@@ -148,7 +124,6 @@ function createHandler() {
sandboxRepository,
getSession,
getSandbox,
- getParticipantByUserId,
transition,
repairIndexStatus,
settleFromMessageState,
@@ -283,33 +258,15 @@ describe("SessionLifecycleHandler", () => {
expect(await response.json()).toEqual({ error: "title must be 200 characters or fewer" });
});
- it("returns 403 when non-participant tries to update title", async () => {
- const { handler, getSession, getParticipantByUserId } = createHandler();
- getSession.mockReturnValue(createSession());
- getParticipantByUserId.mockReturnValue(null);
-
- const response = await handler.updateTitle(
- new Request("http://internal/internal/update-title", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ userId: "user-1", title: "New Title" }),
- })
- );
-
- expect(response.status).toBe(403);
- });
-
it("applies a manual title update and returns the normalized title", async () => {
- const { handler, getSession, getParticipantByUserId, applySessionTitleUpdate } =
- createHandler();
+ const { handler, getSession, applySessionTitleUpdate } = createHandler();
getSession.mockReturnValue(createSession());
- getParticipantByUserId.mockReturnValue(createParticipant());
const response = await handler.updateTitle(
new Request("http://internal/internal/update-title", {
method: "POST",
headers: { "content-type": "application/json" },
- body: JSON.stringify({ userId: "user-1", title: " New Title " }),
+ body: JSON.stringify({ title: " New Title " }),
})
);
@@ -318,60 +275,9 @@ describe("SessionLifecycleHandler", () => {
expect(applySessionTitleUpdate).toHaveBeenCalledWith("New Title", { onlyIfUnset: false });
});
- it("returns 400 for invalid archive body", async () => {
- const { handler, getSession } = createHandler();
- getSession.mockReturnValue(createSession());
-
- const response = await handler.archive(
- new Request("http://internal/internal/archive", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: "{invalid",
- })
- );
-
- expect(response.status).toBe(400);
- expect(await response.json()).toEqual({ error: "Invalid request body" });
- });
-
- it("returns 400 for malformed archive fields", async () => {
- const { handler, getSession, getParticipantByUserId } = createHandler();
- getSession.mockReturnValue(createSession());
-
- const response = await handler.archive(
- new Request("http://internal/internal/archive", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ userId: 123 }),
- })
- );
-
- expect(response.status).toBe(400);
- expect(await response.json()).toEqual({ error: "Invalid request body" });
- expect(getParticipantByUserId).not.toHaveBeenCalled();
- });
-
- it("returns 403 when archive user is not a participant", async () => {
- const { handler, getSession, getParticipantByUserId } = createHandler();
- getSession.mockReturnValue(createSession());
- getParticipantByUserId.mockReturnValue(null);
-
- const response = await handler.archive(
- new Request("http://internal/internal/archive", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ userId: "user-1" }),
- })
- );
-
- expect(response.status).toBe(403);
- expect(await response.json()).toEqual({ error: "Not authorized to archive this session" });
- });
-
- it("archives successfully for participant", async () => {
- const { handler, getSession, getParticipantByUserId, transition } = createHandler();
+ it("archives successfully without participant authorization", async () => {
+ const { handler, getSession, transition } = createHandler();
getSession.mockReturnValue(createSession());
- getParticipantByUserId.mockReturnValue(createParticipant());
transition.mockResolvedValue(true);
const response = await handler.archive(
@@ -490,9 +396,8 @@ describe("SessionLifecycleHandler", () => {
});
it("returns 409 when archiving a session with queued work", async () => {
- const { handler, getSession, getParticipantByUserId, repository, transition } = createHandler();
+ const { handler, getSession, repository, transition } = createHandler();
getSession.mockReturnValue(createSession());
- getParticipantByUserId.mockReturnValue(createParticipant());
repository.getPendingOrProcessingCount.mockReturnValue(1);
const response = await handler.archive(
@@ -507,9 +412,8 @@ describe("SessionLifecycleHandler", () => {
});
it("returns 409 when archiving a cancelled session", async () => {
- const { handler, getSession, getParticipantByUserId, transition } = createHandler();
+ const { handler, getSession, transition } = createHandler();
getSession.mockReturnValue(createSession({ status: "cancelled" }));
- getParticipantByUserId.mockReturnValue(createParticipant());
const response = await handler.archive(
new Request("http://internal/internal/archive", {
@@ -533,10 +437,8 @@ describe("SessionLifecycleHandler", () => {
// state actually produces is covered against real DO storage in
// test/integration/session-lifecycle.test.ts.
it("delegates to the settle service and returns whatever it decides", async () => {
- const { handler, getSession, getParticipantByUserId, transition, settleFromMessageState } =
- createHandler();
+ const { handler, getSession, transition, settleFromMessageState } = createHandler();
getSession.mockReturnValue(createSession({ status: "archived" }));
- getParticipantByUserId.mockReturnValue(createParticipant());
settleFromMessageState.mockResolvedValue("completed");
const response = await handler.unarchive(
@@ -554,9 +456,8 @@ describe("SessionLifecycleHandler", () => {
});
it("returns 409 when unarchiving a session that is not archived", async () => {
- const { handler, getSession, getParticipantByUserId, transition } = createHandler();
+ const { handler, getSession, transition } = createHandler();
getSession.mockReturnValue(createSession({ status: "cancelled" }));
- getParticipantByUserId.mockReturnValue(createParticipant());
const response = await handler.unarchive(
new Request("http://internal/internal/unarchive", {
diff --git a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts
index 479f34e8b..9ba68739d 100644
--- a/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts
+++ b/packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts
@@ -3,7 +3,6 @@ import type { SessionStatus } from "@open-inspect/shared/types/sessions";
import type { SessionCoreRepository } from "../../session-core-repository";
import type { SandboxRepository } from "../../sandbox-repository";
import type { MessageRepository } from "../../message-repository";
-import type { ParticipantRepository } from "../../participant-repository";
import type { SessionStatusService } from "../../session-status-service";
import type { SessionTitleService } from "../../title-service";
import { resolvePublicSessionId } from "../../public-session-id";
@@ -37,14 +36,7 @@ function sessionTitleUpdateStatus(
}
}
-const userIdBodySchema = z.object({
- userId: z.string().optional(),
-});
-
-type UserIdBody = z.infer;
-
const titleUpdateBodySchema = z.object({
- userId: z.string().optional(),
title: z.string().optional(),
});
@@ -55,11 +47,11 @@ type TitleUpdateBody = z.infer;
* updates, archive/unarchive, draft expiry, and cancellation.
*/
export class SessionLifecycleHandler {
+ /** Create the session lifecycle HTTP handler with its persistence and lifecycle services. */
constructor(
private readonly sessionCoreRepository: SessionCoreRepository,
private readonly sandboxRepository: SandboxRepository,
private readonly messageRepository: MessageRepository,
- private readonly participantRepository: ParticipantRepository,
private readonly statusService: SessionStatusService,
private readonly titleService: SessionTitleService,
private readonly sockets: WebSocketManager,
@@ -102,6 +94,7 @@ export class SessionLifecycleHandler {
});
}
+ /** Update the title after route-level lifecycle authorization has succeeded. */
async updateTitle(request: Request): Promise {
const session = this.sessionCoreRepository.getSession();
if (!session) {
@@ -122,23 +115,11 @@ export class SessionLifecycleHandler {
const body: TitleUpdateBody = parseResult.data;
- if (!body.userId) {
- return Response.json({ error: "userId is required" }, { status: 400 });
- }
-
const normalizedTitle = normalizeSessionTitle(body.title);
if (!normalizedTitle.ok) {
return Response.json({ error: normalizedTitle.error }, { status: 400 });
}
- const participant = this.participantRepository.getParticipantByUserId(body.userId);
- if (!participant) {
- return Response.json(
- { error: "Not authorized to update the session title" },
- { status: 403 }
- );
- }
-
const result = this.titleService.applySessionTitleUpdate(normalizedTitle.title, {
onlyIfUnset: false,
});
@@ -149,32 +130,13 @@ export class SessionLifecycleHandler {
return Response.json({ title: result.title });
}
- async archive(request: Request): Promise {
+ /** Archive the session after route-level lifecycle authorization has succeeded. */
+ async archive(): Promise {
const session = this.sessionCoreRepository.getSession();
if (!session) {
return Response.json({ error: "Session not found" }, { status: 404 });
}
- let body: UserIdBody;
- try {
- const result = userIdBodySchema.safeParse(await request.json());
- if (!result.success) {
- return Response.json({ error: "Invalid request body" }, { status: 400 });
- }
- body = result.data;
- } catch {
- return Response.json({ error: "Invalid request body" }, { status: 400 });
- }
-
- if (!body.userId) {
- return Response.json({ error: "userId is required" }, { status: 400 });
- }
-
- const participant = this.participantRepository.getParticipantByUserId(body.userId);
- if (!participant) {
- return Response.json({ error: "Not authorized to archive this session" }, { status: 403 });
- }
-
if (session.status === "cancelled") {
return Response.json({ error: "Cancelled sessions cannot be archived" }, { status: 409 });
}
@@ -240,32 +202,13 @@ export class SessionLifecycleHandler {
return Response.json({ outcome: "archived", status: "archived" });
}
- async unarchive(request: Request): Promise {
+ /** Restore the session after route-level lifecycle authorization has succeeded. */
+ async unarchive(): Promise {
const session = this.sessionCoreRepository.getSession();
if (!session) {
return Response.json({ error: "Session not found" }, { status: 404 });
}
- let body: UserIdBody;
- try {
- const result = userIdBodySchema.safeParse(await request.json());
- if (!result.success) {
- return Response.json({ error: "Invalid request body" }, { status: 400 });
- }
- body = result.data;
- } catch {
- return Response.json({ error: "Invalid request body" }, { status: 400 });
- }
-
- if (!body.userId) {
- return Response.json({ error: "userId is required" }, { status: 400 });
- }
-
- const participant = this.participantRepository.getParticipantByUserId(body.userId);
- if (!participant) {
- return Response.json({ error: "Not authorized to unarchive this session" }, { status: 403 });
- }
-
if (session.status !== "archived") {
return Response.json({ error: "Session is not archived" }, { status: 409 });
}
diff --git a/packages/control-plane/src/session/http/handlers/ws-token.handler.test.ts b/packages/control-plane/src/session/http/handlers/ws-token.handler.test.ts
index bc3122c22..8e407d65c 100644
--- a/packages/control-plane/src/session/http/handlers/ws-token.handler.test.ts
+++ b/packages/control-plane/src/session/http/handlers/ws-token.handler.test.ts
@@ -56,7 +56,20 @@ function createHandler() {
// Bind the request-scoped log so call sites exercise the threading without
// repeating it at every invocation.
const handler = {
- generateWsToken: (request: Request) => wsTokenHandler.generateWsToken(request, log),
+ generateWsToken: async (request: Request) => {
+ const body = (await request.json()) as Record;
+ return wsTokenHandler.generateWsToken(
+ new Request(request.url, {
+ method: request.method,
+ headers: request.headers,
+ body: JSON.stringify({
+ canonicalUserId: "user-1",
+ ...body,
+ }),
+ }),
+ log
+ );
+ },
};
return {
@@ -131,6 +144,7 @@ describe("WsTokenHandler", () => {
participantId: "participant-1",
});
expect(repository.updateParticipantCoalesce).toHaveBeenCalledWith("participant-1", {
+ canonicalUserId: "user-1",
scmUserId: "scm-user-1",
scmLogin: "octocat-updated",
scmName: "Updated Octocat",
@@ -174,6 +188,7 @@ describe("WsTokenHandler", () => {
expect(response.status).toBe(200);
expect(repository.updateParticipantCoalesce).toHaveBeenCalledWith("participant-1", {
+ canonicalUserId: "user-1",
scmUserId: null,
scmLogin: null,
scmName: null,
@@ -215,6 +230,7 @@ describe("WsTokenHandler", () => {
expect(repository.createParticipant).toHaveBeenCalledWith({
id: "participant-new",
userId: "user-1",
+ canonicalUserId: "user-1",
scmUserId: "scm-user-1",
scmLogin: "octocat",
scmName: "The Octocat",
@@ -259,6 +275,7 @@ describe("WsTokenHandler", () => {
expect(repository.createParticipant).toHaveBeenCalledWith({
id: "participant-1",
userId: "user-1",
+ canonicalUserId: "user-1",
scmUserId: null,
scmLogin: null,
scmName: null,
diff --git a/packages/control-plane/src/session/http/handlers/ws-token.handler.ts b/packages/control-plane/src/session/http/handlers/ws-token.handler.ts
index 9abcf95b3..411be2f69 100644
--- a/packages/control-plane/src/session/http/handlers/ws-token.handler.ts
+++ b/packages/control-plane/src/session/http/handlers/ws-token.handler.ts
@@ -7,7 +7,7 @@ const nullableOptionalString = z.string().nullable().optional();
const generateWsTokenRequestSchema = sessionScmDisplayFieldsSchema.extend({
userId: z.string().optional(),
- canonicalUserId: nullableOptionalString,
+ canonicalUserId: z.string().min(1),
scmUserId: nullableOptionalString,
scmTokenEncrypted: nullableOptionalString,
scmRefreshTokenEncrypted: nullableOptionalString,
@@ -29,6 +29,7 @@ export class WsTokenHandler {
private readonly now: () => number = Date.now
) {}
+ /** Mint a token for a participant bound to the authenticated canonical user. */
async generateWsToken(request: Request, log: Logger): Promise {
let raw: unknown;
try {
@@ -71,7 +72,7 @@ export class WsTokenHandler {
(participant.scm_refresh_token_encrypted == null || shouldUpdateTokens);
this.repository.updateParticipantCoalesce(participant.id, {
- ...(body.canonicalUserId ? { canonicalUserId: body.canonicalUserId } : {}),
+ canonicalUserId: body.canonicalUserId,
scmUserId: body.scmUserId ?? null,
scmLogin: body.scmLogin ?? null,
scmName: body.scmName ?? null,
@@ -87,7 +88,7 @@ export class WsTokenHandler {
this.repository.createParticipant({
id,
userId: body.userId,
- ...(body.canonicalUserId ? { canonicalUserId: body.canonicalUserId } : {}),
+ canonicalUserId: body.canonicalUserId,
scmUserId: body.scmUserId ?? null,
scmLogin: body.scmLogin ?? null,
scmName: body.scmName ?? null,
diff --git a/packages/control-plane/src/session/http/routes.test.ts b/packages/control-plane/src/session/http/routes.test.ts
index 728b69baf..d92ce5d3b 100644
--- a/packages/control-plane/src/session/http/routes.test.ts
+++ b/packages/control-plane/src/session/http/routes.test.ts
@@ -21,7 +21,6 @@ describe("createSessionInternalRoutes", () => {
createMediaArtifact: noopHandler(),
recordAttachment: noopHandler(),
listParticipants: noopHandler(),
- addParticipant: noopHandler(),
listEvents: noopHandler(),
listArtifacts: noopHandler(),
listMessages: noopHandler(),
@@ -67,7 +66,6 @@ describe("createSessionInternalRoutes", () => {
`POST ${SessionInternalPaths.createMediaArtifact}`,
`POST ${SessionInternalPaths.attachments}`,
`GET ${SessionInternalPaths.participants}`,
- `POST ${SessionInternalPaths.participants}`,
`GET ${SessionInternalPaths.events}`,
`GET ${SessionInternalPaths.artifacts}`,
`GET ${SessionInternalPaths.messages}`,
diff --git a/packages/control-plane/src/session/http/routes.ts b/packages/control-plane/src/session/http/routes.ts
index d2ca65626..132e709a9 100644
--- a/packages/control-plane/src/session/http/routes.ts
+++ b/packages/control-plane/src/session/http/routes.ts
@@ -19,6 +19,7 @@ export interface SessionInternalRoute {
handler: SessionInternalRouteHandler;
}
+/** Handlers required to serve every internal SessionDO HTTP route. */
export interface SessionInternalRouteHandlers {
init: SessionInternalRouteHandler;
state: SessionInternalRouteHandler;
@@ -32,7 +33,6 @@ export interface SessionInternalRouteHandlers {
createMediaArtifact: SessionInternalRouteHandler;
recordAttachment: SessionInternalRouteHandler;
listParticipants: SessionInternalRouteHandler;
- addParticipant: SessionInternalRouteHandler;
listEvents: SessionInternalRouteHandler;
listArtifacts: SessionInternalRouteHandler;
listMessages: SessionInternalRouteHandler;
@@ -94,11 +94,6 @@ export function createSessionInternalRoutes(
path: SessionInternalPaths.participants,
handler: handlers.listParticipants,
},
- {
- method: "POST",
- path: SessionInternalPaths.participants,
- handler: handlers.addParticipant,
- },
{ method: "GET", path: SessionInternalPaths.events, handler: handlers.listEvents },
{ method: "GET", path: SessionInternalPaths.artifacts, handler: handlers.listArtifacts },
{ method: "GET", path: SessionInternalPaths.messages, handler: handlers.listMessages },
diff --git a/packages/control-plane/src/session/message-queue.test.ts b/packages/control-plane/src/session/message-queue.test.ts
index 93166f99f..c732ce763 100644
--- a/packages/control-plane/src/session/message-queue.test.ts
+++ b/packages/control-plane/src/session/message-queue.test.ts
@@ -103,6 +103,7 @@ function createClientInfo(overrides: Partial = {}): ClientInfo {
status: "active",
lastSeen: 1000,
clientId: "client-1",
+ authorizationExpiresAt: Date.now() + 300_000,
ws: {} as WebSocket,
...overrides,
};
diff --git a/packages/control-plane/src/session/participant-repository.ts b/packages/control-plane/src/session/participant-repository.ts
index e7eeb3f7e..12cf424ee 100644
--- a/packages/control-plane/src/session/participant-repository.ts
+++ b/packages/control-plane/src/session/participant-repository.ts
@@ -3,7 +3,7 @@ import type { SqlStorage } from "./sql-storage";
import type { ParticipantRow } from "./types";
/** Data for creating a participant. */
-export interface CreateParticipantData {
+interface CreateParticipantData {
id: string;
userId: string;
canonicalUserId?: string | null;
@@ -19,7 +19,7 @@ export interface CreateParticipantData {
}
/** Data for updating a participant with COALESCE (only non-null values update). */
-export interface UpdateParticipantData {
+interface UpdateParticipantData {
canonicalUserId?: string | null;
scmUserId?: string | null;
scmLogin?: string | null;
@@ -115,7 +115,9 @@ export class ParticipantRepository {
updateParticipantWsToken(participantId: string, tokenHash: string, createdAt: number): void {
this.sql.exec(
- `UPDATE participants SET ws_auth_token = ?, ws_token_created_at = ? WHERE id = ?`,
+ `UPDATE participants
+ SET ws_auth_token = ?, ws_token_created_at = ?
+ WHERE id = ?`,
tokenHash,
createdAt,
participantId
diff --git a/packages/control-plane/src/session/participant-service.test.ts b/packages/control-plane/src/session/participant-service.test.ts
index f777bf1a3..c9cbddd1a 100644
--- a/packages/control-plane/src/session/participant-service.test.ts
+++ b/packages/control-plane/src/session/participant-service.test.ts
@@ -4,10 +4,10 @@ import type { ParticipantRow } from "./types";
import {
ParticipantService,
getAvatarUrl,
- type ParticipantRepository,
type ParticipantServiceDeps,
type ParticipantServiceEnv,
} from "./participant-service";
+import type { ParticipantRepository } from "./participant-repository";
import type { UserScmTokenStore, ScmTokenRecord, CasResult } from "../db/user-scm-tokens";
// ---- Module-level mocks for centralized refresh tests ----
diff --git a/packages/control-plane/src/session/participant-service.ts b/packages/control-plane/src/session/participant-service.ts
index a4686e844..734fcf70b 100644
--- a/packages/control-plane/src/session/participant-service.ts
+++ b/packages/control-plane/src/session/participant-service.ts
@@ -15,8 +15,6 @@ import type { ParticipantRow } from "./types";
import type { ParticipantRepository } from "./participant-repository";
import { DEFAULT_TOKEN_LIFETIME_MS, type UserScmTokenStore } from "../db/user-scm-tokens";
-export type { ParticipantRepository } from "./participant-repository";
-
/**
* Environment config — only the secrets ParticipantService needs.
*/
diff --git a/packages/control-plane/src/session/presence-service.test.ts b/packages/control-plane/src/session/presence-service.test.ts
index dac3cf7a9..581897282 100644
--- a/packages/control-plane/src/session/presence-service.test.ts
+++ b/packages/control-plane/src/session/presence-service.test.ts
@@ -24,6 +24,7 @@ function createMockClient(overrides?: Partial): ClientInfo {
status: "active",
lastSeen: 1000,
clientId: "client-1",
+ authorizationExpiresAt: Date.now() + 300_000,
ws: {} as WebSocket,
...overrides,
};
diff --git a/packages/control-plane/src/session/schema.test.ts b/packages/control-plane/src/session/schema.test.ts
index b3791c1b7..3625154b1 100644
--- a/packages/control-plane/src/session/schema.test.ts
+++ b/packages/control-plane/src/session/schema.test.ts
@@ -269,6 +269,23 @@ describe("applyMigrations", () => {
expect(migration?.run).toContain("CREATE TABLE IF NOT EXISTS session_repositories");
});
+ it("adds WebSocket authorization lease state for fresh and migrated DOs", () => {
+ expect(SCHEMA_SQL).toContain("authorization_expires_at INTEGER NOT NULL");
+ expect(SCHEMA_SQL).not.toContain("authorization_version");
+
+ const migration = MIGRATIONS.find((entry) => entry.id === 46);
+ expect(typeof migration?.run).toBe("function");
+ const run = migration!.run as (sql: SqlStorage) => void;
+ run(mock.sql);
+ expect(
+ mock.calls.filter(({ query }) => query.includes("ALTER TABLE")).map(({ query }) => query)
+ ).toEqual([
+ expect.stringContaining(
+ "ws_client_mapping ADD COLUMN authorization_expires_at INTEGER NOT NULL DEFAULT 0"
+ ),
+ ]);
+ });
+
it("keeps repository context consistent at the session table boundary", () => {
expect(SCHEMA_SQL).toContain("(repo_owner IS NULL) = (repo_name IS NULL)");
expect(SCHEMA_SQL).toContain("repo_owner IS NOT NULL");
diff --git a/packages/control-plane/src/session/schema.ts b/packages/control-plane/src/session/schema.ts
index db979604e..8ee706494 100644
--- a/packages/control-plane/src/session/schema.ts
+++ b/packages/control-plane/src/session/schema.ts
@@ -203,6 +203,7 @@ CREATE TABLE IF NOT EXISTS ws_client_mapping (
participant_id TEXT NOT NULL,
client_id TEXT,
created_at INTEGER NOT NULL,
+ authorization_expires_at INTEGER NOT NULL,
FOREIGN KEY (participant_id) REFERENCES participants(id)
);
`;
@@ -619,6 +620,16 @@ export const MIGRATIONS: readonly SchemaMigration[] = [
ON messages(autofix_pr_key, created_at) WHERE autofix_pr_key IS NOT NULL`);
},
},
+ {
+ id: 46,
+ description: "Add WebSocket authorization leases",
+ run: (sql) => {
+ runMigration(
+ sql,
+ `ALTER TABLE ws_client_mapping ADD COLUMN authorization_expires_at INTEGER NOT NULL DEFAULT 0`
+ );
+ },
+ },
];
/**
diff --git a/packages/control-plane/src/session/websocket-manager.test.ts b/packages/control-plane/src/session/websocket-manager.test.ts
index 9934c724e..d12e50aa5 100644
--- a/packages/control-plane/src/session/websocket-manager.test.ts
+++ b/packages/control-plane/src/session/websocket-manager.test.ts
@@ -102,6 +102,7 @@ function createMockRepository() {
participantId: string;
clientId: string;
createdAt: number;
+ authorizationExpiresAt: number;
}> = [];
const repo = {
@@ -113,6 +114,7 @@ function createMockRepository() {
participantId: string;
clientId: string;
createdAt: number;
+ authorizationExpiresAt: number;
}) => {
upsertCalls.push(data);
mappings.set(data.wsId, {
@@ -122,8 +124,21 @@ function createMockRepository() {
scm_name: null,
auth_name: null,
scm_login: null,
+ authorization_expires_at: data.authorizationExpiresAt,
});
},
+ deleteWsClientMapping: (wsId: string) => mappings.delete(wsId),
+ deleteExpiredMappings: (now: number) => {
+ for (const [wsId, mapping] of mappings) {
+ if (mapping.authorization_expires_at <= now) mappings.delete(wsId);
+ }
+ },
+ getNextAuthorizationExpiry: () => {
+ const expiries = Array.from(mappings.values()).map(
+ (mapping) => mapping.authorization_expires_at
+ );
+ return expiries.length > 0 ? Math.min(...expiries) : null;
+ },
} as unknown as SandboxRepository;
return {
@@ -148,6 +163,7 @@ function createClientInfo(overrides: Partial = {}): ClientInfo {
status: "active",
lastSeen: Date.now(),
clientId: "client-1",
+ authorizationExpiresAt: Date.now() + 300_000,
ws: createFakeWebSocket(),
...overrides,
};
@@ -188,17 +204,30 @@ const TEST_CONFIG: WebSocketManagerConfig = { authTimeoutMs: 100 };
function createManager() {
const fakeCtx = createFakeCtx();
const mockRepo = createMockRepository();
+ const alarmScheduler = {
+ schedule: vi.fn(async () => {}),
+ cancel: vi.fn(async () => {}),
+ current: vi.fn(async () => null),
+ };
const log = createMockLogger();
const manager = new SessionWebSocketManagerImpl(
fakeCtx.state,
mockRepo.repo,
mockRepo.repo as unknown as WsClientMappingRepository,
+ alarmScheduler,
log,
TEST_CONFIG
);
- return { manager, sockets: fakeCtx.sockets, state: fakeCtx.state, mockRepo, log };
+ return {
+ manager,
+ sockets: fakeCtx.sockets,
+ state: fakeCtx.state,
+ mockRepo,
+ alarmScheduler,
+ log,
+ };
}
// ---------------------------------------------------------------------------
@@ -493,21 +522,31 @@ describe("SessionWebSocketManagerImpl", () => {
});
describe("client registry", () => {
- it("setClient / getClient round-trips", () => {
+ it("returns a cached live client", () => {
const { manager } = createManager();
const ws = createFakeWebSocket();
const info = createClientInfo({ ws });
manager.setClient(ws, info);
- expect(manager.getClient(ws)).toBe(info);
+ expect(manager.lookupClient(ws)).toEqual({ kind: "cached", client: info });
});
- it("getClient returns null for unknown socket", () => {
+ it("returns missing for an unknown socket", () => {
const { manager } = createManager();
const ws = createFakeWebSocket();
- expect(manager.getClient(ws)).toBeNull();
+ expect(manager.lookupClient(ws)).toEqual({ kind: "missing" });
+ });
+
+ it("rejects an expired live client on inbound lookup", () => {
+ const { manager, sockets } = createManager();
+ const ws = createFakeWebSocket();
+ sockets.set(ws, ["wsid:ws-expired"]);
+ manager.setClient(ws, createClientInfo({ ws, authorizationExpiresAt: Date.now() - 1 }));
+
+ expect(manager.lookupClient(ws)).toEqual({ kind: "authorization_rejected" });
+ expect(ws.close).toHaveBeenCalledWith(4010, "Authorization expired or changed");
});
it("removeClient returns and removes the client", () => {
@@ -519,7 +558,7 @@ describe("SessionWebSocketManagerImpl", () => {
const removed = manager.removeClient(ws);
expect(removed).toBe(info);
- expect(manager.getClient(ws)).toBeNull();
+ expect(manager.lookupClient(ws)).toEqual({ kind: "missing" });
});
it("removeClient returns null for unknown socket", () => {
@@ -530,7 +569,7 @@ describe("SessionWebSocketManagerImpl", () => {
});
});
- describe("recoverClientMapping", () => {
+ describe("lookupClient", () => {
it("returns mapping when wsId tag and DB mapping exist", () => {
const { manager, sockets, mockRepo } = createManager();
const ws = createFakeWebSocket();
@@ -543,10 +582,11 @@ describe("SessionWebSocketManagerImpl", () => {
scm_name: "Test",
auth_name: null,
scm_login: "testuser",
+ authorization_expires_at: Date.now() + 300_000,
};
mockRepo.addMapping("ws-42", mapping);
- expect(manager.recoverClientMapping(ws)).toEqual(mapping);
+ expect(manager.lookupClient(ws)).toEqual({ kind: "recovered", mapping });
});
it("returns null for sandbox-tagged sockets", () => {
@@ -555,7 +595,7 @@ describe("SessionWebSocketManagerImpl", () => {
sockets.set(ws, ["sandbox"]);
- expect(manager.recoverClientMapping(ws)).toBeNull();
+ expect(manager.lookupClient(ws)).toEqual({ kind: "missing" });
});
it("returns null when no wsId tag", () => {
@@ -564,7 +604,7 @@ describe("SessionWebSocketManagerImpl", () => {
sockets.set(ws, []);
- expect(manager.recoverClientMapping(ws)).toBeNull();
+ expect(manager.lookupClient(ws)).toEqual({ kind: "missing" });
});
it("returns null when no DB mapping found", () => {
@@ -573,22 +613,54 @@ describe("SessionWebSocketManagerImpl", () => {
sockets.set(ws, ["wsid:ws-nonexistent"]);
- expect(manager.recoverClientMapping(ws)).toBeNull();
+ expect(manager.lookupClient(ws)).toEqual({ kind: "missing" });
});
- });
- describe("persistClientMapping", () => {
- it("calls repository.upsertWsClientMapping", () => {
- const { manager, mockRepo } = createManager();
+ it("rejects an expired mapping during hibernation recovery", () => {
+ const { manager, sockets, mockRepo } = createManager();
+ const ws = createFakeWebSocket();
+ sockets.set(ws, ["wsid:ws-expired"]);
+ mockRepo.addMapping("ws-expired", {
+ participant_id: "p-1",
+ client_id: "c-1",
+ user_id: "u-1",
+ scm_name: null,
+ scm_login: null,
+ authorization_expires_at: Date.now() - 1,
+ });
- manager.persistClientMapping("ws-1", "part-1", "client-1");
+ expect(manager.lookupClient(ws)).toEqual({ kind: "authorization_rejected" });
+ expect(ws.close).toHaveBeenCalledWith(4010, "Authorization expired or changed");
+ });
+ it("rejects an expired in-memory lease without attempting recovery", () => {
+ const { manager, sockets } = createManager();
+ const ws = createFakeWebSocket();
+ sockets.set(ws, ["wsid:ws-expired"]);
+ manager.setClient(ws, createClientInfo({ ws, authorizationExpiresAt: Date.now() - 1 }));
+
+ expect(manager.lookupClient(ws)).toEqual({ kind: "authorization_rejected" });
+ expect(ws.close).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("grantLease", () => {
+ it("mints, persists, and schedules one authorization deadline", async () => {
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
+ const { manager, alarmScheduler, mockRepo, sockets } = createManager();
+ const ws = createFakeWebSocket();
+ sockets.set(ws, ["wsid:ws-1"]);
+
+ await expect(manager.grantLease(ws, "part-1", "client-1")).resolves.toBe(301_000);
+ expect(alarmScheduler.schedule).toHaveBeenCalledWith(301_000);
expect(mockRepo.upsertCalls).toHaveLength(1);
expect(mockRepo.upsertCalls[0]).toMatchObject({
wsId: "ws-1",
participantId: "part-1",
clientId: "client-1",
+ authorizationExpiresAt: 301_000,
});
+ now.mockRestore();
});
});
@@ -602,6 +674,7 @@ describe("SessionWebSocketManagerImpl", () => {
scm_name: null,
auth_name: null,
scm_login: null,
+ authorization_expires_at: Date.now() + 300_000,
});
expect(manager.hasPersistedMapping("ws-1")).toBe(true);
@@ -723,6 +796,7 @@ describe("SessionWebSocketManagerImpl", () => {
scm_name: null,
auth_name: null,
scm_login: null,
+ authorization_expires_at: Date.now() + 300_000,
});
const called: WebSocket[] = [];
@@ -743,6 +817,39 @@ describe("SessionWebSocketManagerImpl", () => {
expect(called).toHaveLength(0);
});
+ it("rejects an expired live client instead of broadcasting", () => {
+ const { manager, sockets } = createManager();
+ const ws = createFakeWebSocket();
+ sockets.set(ws, ["wsid:ws-expired"]);
+ manager.setClient(ws, createClientInfo({ ws, authorizationExpiresAt: Date.now() - 1 }));
+
+ const called: WebSocket[] = [];
+ manager.forEachClientSocket("authenticated_only", (client) => called.push(client));
+
+ expect(called).toEqual([]);
+ expect(ws.close).toHaveBeenCalledWith(4010, "Authorization expired or changed");
+ });
+
+ it("rejects an expired hibernated mapping instead of broadcasting", () => {
+ const { manager, sockets, mockRepo } = createManager();
+ const ws = createFakeWebSocket();
+ sockets.set(ws, ["wsid:ws-expired"]);
+ mockRepo.addMapping("ws-expired", {
+ participant_id: "p-1",
+ client_id: "c-1",
+ user_id: "u-1",
+ scm_name: null,
+ scm_login: null,
+ authorization_expires_at: Date.now() - 1,
+ });
+
+ const called: WebSocket[] = [];
+ manager.forEachClientSocket("authenticated_only", (client) => called.push(client));
+
+ expect(called).toEqual([]);
+ expect(ws.close).toHaveBeenCalledWith(4010, "Authorization expired or changed");
+ });
+
it("broadcast pattern delivers to authenticated clients and skips unauthenticated", () => {
const { manager, sockets, mockRepo } = createManager();
@@ -761,6 +868,7 @@ describe("SessionWebSocketManagerImpl", () => {
scm_name: null,
auth_name: null,
scm_login: null,
+ authorization_expires_at: Date.now() + 300_000,
});
// Unauthenticated client (connected but never subscribed)
@@ -799,6 +907,34 @@ describe("SessionWebSocketManagerImpl", () => {
});
});
+ describe("expireAuthorizationLeases", () => {
+ it("closes expired live mappings and schedules the next deadline", async () => {
+ const { manager, sockets, mockRepo, alarmScheduler } = createManager();
+ const expired = createFakeWebSocket();
+ sockets.set(expired, ["wsid:expired"]);
+ mockRepo.addMapping("expired", {
+ participant_id: "p-1",
+ client_id: "c-1",
+ user_id: "u-1",
+ scm_name: null,
+ scm_login: null,
+ authorization_expires_at: 1_000,
+ });
+ mockRepo.addMapping("future", {
+ participant_id: "p-2",
+ client_id: "c-2",
+ user_id: "u-2",
+ scm_name: null,
+ scm_login: null,
+ authorization_expires_at: 3_000,
+ });
+
+ await manager.expireAuthorizationLeases(2_000);
+ expect(expired.close).toHaveBeenCalledWith(4010, "Authorization expired or changed");
+ expect(alarmScheduler.schedule).toHaveBeenCalledWith(3_000);
+ });
+ });
+
describe("enforceAuthTimeout", () => {
it("does not close socket if authenticated in-memory before timeout", async () => {
const { manager, sockets } = createManager();
@@ -824,6 +960,7 @@ describe("SessionWebSocketManagerImpl", () => {
scm_name: null,
auth_name: null,
scm_login: null,
+ authorization_expires_at: Date.now() + 300_000,
});
await manager.enforceAuthTimeout(ws, "ws-1");
diff --git a/packages/control-plane/src/session/websocket-manager.ts b/packages/control-plane/src/session/websocket-manager.ts
index 36c0e8b42..25a3ca3b5 100644
--- a/packages/control-plane/src/session/websocket-manager.ts
+++ b/packages/control-plane/src/session/websocket-manager.ts
@@ -2,11 +2,12 @@
* SessionWebSocketManager — centralizes all Cloudflare WebSocket API usage
* into a single, testable module.
*
- * The manager is a registry for ClientInfo, not a factory. The DO builds
- * ClientInfo and stores it here via setClient/getClient.
+ * The manager owns socket identity, persistence, and authorization leases.
+ * The DO builds ClientInfo and stores it here after snapshot synchronization.
*/
import type { Logger } from "../logger";
+import type { AlarmScheduler } from "../platform-ports";
import type { ClientInfo } from "../types";
import type { ConnectionClassification } from "./ports";
import type { SandboxRepository } from "./sandbox-repository";
@@ -14,6 +15,11 @@ import type {
WsClientMappingRepository,
WsClientMappingResult,
} from "./ws-client-mapping-repository";
+import {
+ WS_AUTHORIZATION_REVOKED_REASON,
+ WS_AUTHORIZATION_LEASE_MS,
+ WS_CLOSE_AUTHORIZATION_REVOKED,
+} from "./authorization-lease";
/** Configuration for the WebSocket manager. */
export interface WebSocketManagerConfig {
@@ -24,6 +30,7 @@ export interface WebSocketManagerConfig {
// Interface
// ---------------------------------------------------------------------------
+/** Manages session sockets, client identity, and expiring authorization leases. */
export interface SessionWebSocketManager {
/** Create the client/server WebSocket pair for an upgrade response. */
createUpgradeSockets(): { client: WebSocket; server: WebSocket };
@@ -56,17 +63,20 @@ export interface SessionWebSocketManager {
clearSandboxSocketIfMatch(ws: WebSocket): boolean;
setClient(ws: WebSocket, info: ClientInfo): void;
- getClient(ws: WebSocket): ClientInfo | null;
removeClient(ws: WebSocket): ClientInfo | null;
- /** Returns raw DB mapping for hibernation recovery. The DO builds ClientInfo from this. */
- recoverClientMapping(ws: WebSocket): WsClientMappingResult | null;
+ /** Return a live client or its persisted hibernation mapping, rejecting expired leases. */
+ lookupClient(ws: WebSocket): ClientLookup;
- /** Persist ws-to-participant mapping for hibernation survival. */
- persistClientMapping(wsId: string, participantId: string, clientId: string): void;
+ /** Mint, persist, and schedule an authorization lease. */
+ grantLease(ws: WebSocket, participantId: string, clientId: string): Promise;
+
+ /** Close expired sockets, delete expired mappings, and schedule the next lease deadline. */
+ expireAuthorizationLeases(now: number): Promise;
setClientSynchronizing(ws: WebSocket, synchronizing: boolean): void;
isClientSynchronizing(ws: WebSocket): boolean;
+ /** Return whether the client has an unexpired authorization lease. */
isClientAuthenticated(ws: WebSocket): boolean;
/** Check if a wsId has a persisted mapping (used by auth timeout). */
@@ -75,6 +85,7 @@ export interface SessionWebSocketManager {
send(ws: WebSocket, message: string | object): boolean;
close(ws: WebSocket, code: number, reason: string): void;
+ /** Visit client sockets, optionally limiting the visit to unexpired authorization leases. */
forEachClientSocket(
mode: "all_clients" | "authenticated_only",
fn: (ws: WebSocket) => void
@@ -85,19 +96,29 @@ export interface SessionWebSocketManager {
getConnectedClientCount(): number;
}
+/** Result of resolving a client while enforcing its authorization lease. */
+export type ClientLookup =
+ | { kind: "cached"; client: ClientInfo }
+ | { kind: "recovered"; mapping: WsClientMappingResult }
+ | { kind: "authorization_rejected" }
+ | { kind: "missing" };
+
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
+/** Durable Object WebSocket manager with persisted authorization leases. */
export class SessionWebSocketManagerImpl implements SessionWebSocketManager {
private clients = new Map();
private synchronizingClients = new Set();
private sandboxWs: WebSocket | null = null;
+ /** Create a WebSocket manager backed by Durable Object state and persisted client mappings. */
constructor(
private readonly ctx: DurableObjectState,
private readonly sandboxRepository: SandboxRepository,
private readonly wsClientMappingRepository: WsClientMappingRepository,
+ private readonly alarmScheduler: AlarmScheduler,
private readonly log: Logger,
private readonly config: WebSocketManagerConfig
) {}
@@ -234,10 +255,6 @@ export class SessionWebSocketManagerImpl implements SessionWebSocketManager {
this.clients.set(ws, info);
}
- getClient(ws: WebSocket): ClientInfo | null {
- return this.clients.get(ws) ?? null;
- }
-
removeClient(ws: WebSocket): ClientInfo | null {
const client = this.clients.get(ws) ?? null;
this.clients.delete(ws);
@@ -248,19 +265,63 @@ export class SessionWebSocketManagerImpl implements SessionWebSocketManager {
// Hibernation recovery for client identity
// -------------------------------------------------------------------------
- recoverClientMapping(ws: WebSocket): WsClientMappingResult | null {
+ /** Return cached or persisted client state, closing the socket if its lease expired. */
+ lookupClient(ws: WebSocket): ClientLookup {
+ const client = this.clients.get(ws);
+ if (client) {
+ if (client.authorizationExpiresAt <= Date.now()) {
+ this.rejectExpiredAuthorization(ws, this.classify(ws));
+ return { kind: "authorization_rejected" };
+ }
+ return { kind: "cached", client };
+ }
+
const parsed = this.classify(ws);
- if (parsed.kind !== "client" || !parsed.wsId) return null;
- return this.wsClientMappingRepository.getWsClientMapping(parsed.wsId);
+ if (parsed.kind !== "client" || !parsed.wsId) return { kind: "missing" };
+ const mapping = this.wsClientMappingRepository.getWsClientMapping(parsed.wsId);
+ if (!mapping) return { kind: "missing" };
+ if (mapping.authorization_expires_at <= Date.now()) {
+ this.rejectExpiredAuthorization(ws, parsed);
+ return { kind: "authorization_rejected" };
+ }
+ return { kind: "recovered", mapping };
}
- persistClientMapping(wsId: string, participantId: string, clientId: string): void {
+ /** Persist a new authorization lease and schedule its expiration deadline. */
+ async grantLease(ws: WebSocket, participantId: string, clientId: string): Promise {
+ const parsed = this.classify(ws);
+ if (parsed.kind !== "client" || !parsed.wsId) {
+ throw new Error("Cannot grant an authorization lease without a client WebSocket ID");
+ }
+ const expiresAt = Date.now() + WS_AUTHORIZATION_LEASE_MS;
+ await this.alarmScheduler.schedule(expiresAt);
this.wsClientMappingRepository.upsertWsClientMapping({
- wsId,
+ wsId: parsed.wsId,
participantId,
clientId,
createdAt: Date.now(),
+ authorizationExpiresAt: expiresAt,
+ });
+ this.log.debug("Stored ws_client_mapping", {
+ ws_id: parsed.wsId,
+ participant_id: participantId,
});
+ return expiresAt;
+ }
+
+ /** Close and remove expired client leases, then schedule the next deadline. */
+ async expireAuthorizationLeases(now: number): Promise {
+ for (const ws of this.ctx.getWebSockets()) {
+ const parsed = this.classify(ws);
+ if (parsed.kind !== "client") continue;
+ const expiresAt = this.authorizationExpiry(ws, parsed);
+ if (expiresAt !== null && expiresAt <= now) {
+ this.rejectExpiredAuthorization(ws, parsed);
+ }
+ }
+ this.wsClientMappingRepository.deleteExpiredMappings(now);
+ const nextExpiry = this.wsClientMappingRepository.getNextAuthorizationExpiry();
+ if (nextExpiry !== null) await this.alarmScheduler.schedule(nextExpiry);
}
setClientSynchronizing(ws: WebSocket, synchronizing: boolean): void {
@@ -272,6 +333,7 @@ export class SessionWebSocketManagerImpl implements SessionWebSocketManager {
return this.synchronizingClients.has(ws);
}
+ /** Return whether the client has an unexpired authorization lease. */
isClientAuthenticated(ws: WebSocket): boolean {
return this.isAuthenticated(ws, this.classify(ws));
}
@@ -311,6 +373,7 @@ export class SessionWebSocketManagerImpl implements SessionWebSocketManager {
// Broadcast
// -------------------------------------------------------------------------
+ /** Visit client sockets, optionally limiting the visit to unexpired authorization leases. */
forEachClientSocket(
mode: "all_clients" | "authenticated_only",
fn: (ws: WebSocket) => void
@@ -332,11 +395,26 @@ export class SessionWebSocketManagerImpl implements SessionWebSocketManager {
* either in-memory or via persisted DB mapping (post-hibernation).
*/
private isAuthenticated(ws: WebSocket, parsed: ConnectionClassification): boolean {
- if (this.clients.has(ws)) return true;
+ const expiresAt = this.authorizationExpiry(ws, parsed);
+ if (expiresAt === null) return false;
+ if (expiresAt > Date.now()) return true;
+ this.rejectExpiredAuthorization(ws, parsed);
+ return false;
+ }
+
+ private authorizationExpiry(ws: WebSocket, parsed: ConnectionClassification): number | null {
+ const client = this.clients.get(ws);
+ if (client) return client.authorizationExpiresAt;
+ if (parsed.kind !== "client" || !parsed.wsId) return null;
+ const mapping = this.wsClientMappingRepository.getWsClientMapping(parsed.wsId);
+ return mapping?.authorization_expires_at ?? null;
+ }
+
+ private rejectExpiredAuthorization(ws: WebSocket, parsed: ConnectionClassification): void {
if (parsed.kind === "client" && parsed.wsId) {
- return this.wsClientMappingRepository.hasWsClientMapping(parsed.wsId);
+ this.wsClientMappingRepository.deleteWsClientMapping(parsed.wsId);
}
- return false;
+ this.close(ws, WS_CLOSE_AUTHORIZATION_REVOKED, WS_AUTHORIZATION_REVOKED_REASON);
}
// -------------------------------------------------------------------------
diff --git a/packages/control-plane/src/session/ws-client-mapping-repository.test.ts b/packages/control-plane/src/session/ws-client-mapping-repository.test.ts
index 55a2fad9a..8ee7fd034 100644
--- a/packages/control-plane/src/session/ws-client-mapping-repository.test.ts
+++ b/packages/control-plane/src/session/ws-client-mapping-repository.test.ts
@@ -29,9 +29,10 @@ describe("WsClientMappingRepository", () => {
participantId: "p-1",
clientId: "client-1",
createdAt: 1000,
+ authorizationExpiresAt: 2000,
});
expect(mock.calls[0].query).toContain("INSERT OR REPLACE INTO ws_client_mapping");
- expect(mock.calls[0].params).toEqual(["ws-1", "p-1", "client-1", 1000]);
+ expect(mock.calls[0].params).toEqual(["ws-1", "p-1", "client-1", 1000, 2000]);
});
it("restores a mapping with joined participant data", () => {
diff --git a/packages/control-plane/src/session/ws-client-mapping-repository.ts b/packages/control-plane/src/session/ws-client-mapping-repository.ts
index 18fb6bbb9..846a3b957 100644
--- a/packages/control-plane/src/session/ws-client-mapping-repository.ts
+++ b/packages/control-plane/src/session/ws-client-mapping-repository.ts
@@ -10,6 +10,8 @@ export interface WsClientMappingResult {
scm_login: string | null;
/** Dormant legacy column may still be present on older mapping fixtures. */
auth_name?: string | null;
+ /** Wall-clock time when the persisted authorization lease expires. */
+ authorization_expires_at: number;
}
/** Data for a WS client mapping. */
@@ -18,28 +20,35 @@ export interface WsClientMappingData {
participantId: string;
clientId: string;
createdAt: number;
+ /** Wall-clock time when the persisted authorization lease expires. */
+ authorizationExpiresAt: number;
}
/** Persistence for WebSocket client mappings scoped to one session. */
export class WsClientMappingRepository {
constructor(private readonly sql: SqlStorage) {}
+ /** Persist a client mapping and its authorization expiration. */
upsertWsClientMapping(data: WsClientMappingData): void {
this.sql.exec(
- `INSERT OR REPLACE INTO ws_client_mapping (ws_id, participant_id, client_id, created_at)
- VALUES (?, ?, ?, ?)`,
+ `INSERT OR REPLACE INTO ws_client_mapping
+ (ws_id, participant_id, client_id, created_at, authorization_expires_at)
+ VALUES (?, ?, ?, ?, ?)`,
data.wsId,
data.participantId,
data.clientId,
- data.createdAt
+ data.createdAt,
+ data.authorizationExpiresAt
);
}
+ /** Load client identity and authorization expiration for hibernation recovery. */
getWsClientMapping(wsId: string): WsClientMappingResult | null {
// Keep this indexed JOIN in one query: both tables share the session-local store,
// and this read is on the hibernation-recovery hot path.
const result = this.sql.exec(
- `SELECT m.participant_id, m.client_id, p.user_id, p.canonical_user_id, p.scm_name, p.scm_login
+ `SELECT m.participant_id, m.client_id, m.authorization_expires_at,
+ p.user_id, p.canonical_user_id, p.scm_name, p.scm_login
FROM ws_client_mapping m
JOIN participants p ON m.participant_id = p.id
WHERE m.ws_id = ?`,
@@ -55,4 +64,22 @@ export class WsClientMappingRepository {
);
return result.toArray().length > 0;
}
+
+ /** Delete one persisted client mapping. */
+ deleteWsClientMapping(wsId: string): void {
+ this.sql.exec(`DELETE FROM ws_client_mapping WHERE ws_id = ?`, wsId);
+ }
+
+ /** Delete all authorization mappings expired at or before the given time. */
+ deleteExpiredMappings(now: number): void {
+ this.sql.exec(`DELETE FROM ws_client_mapping WHERE authorization_expires_at <= ?`, now);
+ }
+
+ /** Return the earliest persisted authorization expiration, if any. */
+ getNextAuthorizationExpiry(): number | null {
+ const rows = this.sql
+ .exec(`SELECT MIN(authorization_expires_at) AS expires_at FROM ws_client_mapping`)
+ .toArray() as Array<{ expires_at: number | null }>;
+ return rows[0]?.expires_at ?? null;
+ }
}
diff --git a/packages/control-plane/src/types.ts b/packages/control-plane/src/types.ts
index 3dc468f9c..d9879b3ab 100644
--- a/packages/control-plane/src/types.ts
+++ b/packages/control-plane/src/types.ts
@@ -111,7 +111,7 @@ export interface Env {
LOG_LEVEL?: string; // "debug" | "info" | "warn" | "error" (default: "info")
}
-// Client info (stored in DO memory)
+/** Authenticated client state stored in Durable Object memory. */
export interface ClientInfo {
participantId: string;
userId: string;
@@ -120,6 +120,8 @@ export interface ClientInfo {
status: "active" | "idle" | "away";
lastSeen: number;
clientId: string;
+ /** Wall-clock time when this connection's authorization lease expires. */
+ authorizationExpiresAt: number;
ws: WebSocket;
lastFetchHistoryAtMs?: number;
}
diff --git a/packages/control-plane/test/integration/durable-object-eviction.test.ts b/packages/control-plane/test/integration/durable-object-eviction.test.ts
index 27d75a60c..3542b1f8c 100644
--- a/packages/control-plane/test/integration/durable-object-eviction.test.ts
+++ b/packages/control-plane/test/integration/durable-object-eviction.test.ts
@@ -125,7 +125,10 @@ describe("SessionDO eviction and hibernation restore", () => {
const tokenResponse = await stub.fetch("http://internal/internal/ws-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ userId: "user-1" }),
+ body: JSON.stringify({
+ userId: "user-1",
+ canonicalUserId: "user-1",
+ }),
});
const { participantId } = await tokenResponse.json<{ participantId: string }>();
diff --git a/packages/control-plane/test/integration/helpers.ts b/packages/control-plane/test/integration/helpers.ts
index 510c758cb..83fff66d4 100644
--- a/packages/control-plane/test/integration/helpers.ts
+++ b/packages/control-plane/test/integration/helpers.ts
@@ -101,7 +101,7 @@ async function testBrowserSessionCookie(initialRole: InitialUserRole): Promise = {}
+): Promise<{ token: string; participantId: string }> {
+ const stub = env.SESSION.get(env.SESSION.idFromName(sessionName));
+ const canonicalUserId = opts.canonicalUserId ?? opts.userId ?? "user-1";
+ const now = Date.now();
+ await env.DB.prepare(
+ `INSERT OR IGNORE INTO users (id, display_name, created_at, updated_at)
+ VALUES (?, ?, ?, ?)`
+ )
+ .bind(canonicalUserId, "WebSocket Test User", now, now)
+ .run();
+ const tokenRes = await stub.fetch("http://internal/internal/ws-token", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ userId: opts.userId ?? "user-1",
+ canonicalUserId,
+ scmLogin: opts.scmLogin,
+ scmName: opts.scmName,
+ }),
+ });
+ if (!tokenRes.ok) throw new Error(`Token issuance failed: ${tokenRes.status}`);
+ return tokenRes.json<{ token: string; participantId: string }>();
+}
+
// Overloaded on the `subscribe` discriminant: a subscribed socket always
// resolves its token, participant, and replay messages; a bare socket never
// carries them.
@@ -481,23 +508,7 @@ export async function openClientWs(sessionName: string, opts?: OpenClientWsOpts)
return { ws };
}
- // Generate a WS token via the DO
- const id = env.SESSION.idFromName(sessionName);
- const stub = env.SESSION.get(id);
- const tokenRes = await stub.fetch("http://internal/internal/ws-token", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- userId: opts.userId ?? "user-1",
- canonicalUserId: opts.canonicalUserId,
- scmLogin: opts.scmLogin,
- scmName: opts.scmName,
- }),
- });
- const { token, participantId } = await tokenRes.json<{
- token: string;
- participantId: string;
- }>();
+ const { token, participantId } = await issueClientWsToken(sessionName, opts);
// Start collecting BEFORE sending subscribe to avoid race.
// The subscribed message now includes batched replay data, so we terminate on it
diff --git a/packages/control-plane/test/integration/session-lifecycle.test.ts b/packages/control-plane/test/integration/session-lifecycle.test.ts
index a048866aa..fa240e086 100644
--- a/packages/control-plane/test/integration/session-lifecycle.test.ts
+++ b/packages/control-plane/test/integration/session-lifecycle.test.ts
@@ -59,7 +59,7 @@ describe("POST /internal/archive", () => {
expect(state.status).toBe("archived");
});
- it("archive rejects non-participant", async () => {
+ it("archive does not use participant identity for authorization", async () => {
const { stub } = await initSession({ userId: "user-1" });
const res = await stub.fetch("http://internal/internal/archive", {
@@ -68,7 +68,7 @@ describe("POST /internal/archive", () => {
body: JSON.stringify({ userId: "stranger" }),
});
- expect(res.status).toBe(403);
+ expect(res.status).toBe(200);
});
});
diff --git a/packages/control-plane/test/integration/session-repositories.test.ts b/packages/control-plane/test/integration/session-repositories.test.ts
index 3171a9f63..44054f124 100644
--- a/packages/control-plane/test/integration/session-repositories.test.ts
+++ b/packages/control-plane/test/integration/session-repositories.test.ts
@@ -141,37 +141,6 @@ describe("D1 session index repositories", () => {
expect(sessions[0].repositories).toBeUndefined();
});
- it("list repo filters match secondary members", async () => {
- const store = new SessionIndexStore(env.DB);
- await store.create(
- makeEntry("multi-filter", [
- { repoOwner: "acme", repoName: "frontend", repoId: 1, baseBranch: "main" },
- { repoOwner: "acme", repoName: "backend", repoId: 2, baseBranch: "main" },
- ])
- );
- await store.create(
- makeEntry("other-filter", [
- { repoOwner: "acme", repoName: "unrelated", repoId: 3, baseBranch: "main" },
- ])
- );
-
- const bySecondary = await store.list({ repoOwner: "acme", repoName: "backend" });
- expect(bySecondary.sessions.map((s) => s.id)).toEqual(["multi-filter"]);
-
- const byPrimary = await store.list({ repoOwner: "acme", repoName: "frontend" });
- expect(byPrimary.sessions.map((s) => s.id)).toEqual(["multi-filter"]);
- });
-
- it("list repo filters fall back to scalars for pre-feature sessions", async () => {
- const store = new SessionIndexStore(env.DB);
- // No repositories list — simulates a session created before the
- // membership table existed (scalar columns only).
- await store.create(makeEntry("legacy-filter"));
-
- const result = await store.list({ repoOwner: "acme", repoName: "web-app" });
- expect(result.sessions.map((s) => s.id)).toEqual(["legacy-filter"]);
- });
-
it("deletes member rows together with the session", async () => {
const store = new SessionIndexStore(env.DB);
await store.create(
diff --git a/packages/control-plane/test/integration/websocket-client.test.ts b/packages/control-plane/test/integration/websocket-client.test.ts
index e3b4b2773..14ff444a4 100644
--- a/packages/control-plane/test/integration/websocket-client.test.ts
+++ b/packages/control-plane/test/integration/websocket-client.test.ts
@@ -8,6 +8,7 @@ import {
queryDO,
seedMessage,
waitForSandboxStatus,
+ issueClientWsToken,
} from "./helpers";
import { DEFAULT_REPLAY_LIMIT } from "../../src/session/event-stream";
import { MAX_UNFINISHED_PROMPTS } from "@open-inspect/shared/types/prompts";
@@ -190,7 +191,10 @@ describe("Client WebSocket (via SELF.fetch)", () => {
const tokenRes = await doStub.fetch("http://internal/internal/ws-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ userId: "user-1" }),
+ body: JSON.stringify({
+ userId: "user-1",
+ canonicalUserId: "user-1",
+ }),
});
const { token } = await tokenRes.json<{ token: string }>();
@@ -223,6 +227,95 @@ describe("Client WebSocket (via SELF.fetch)", () => {
expect(reason).toBe("Token expired");
});
+ it("allows workspace collaborators without a session relationship", async () => {
+ const name = `ws-client-workspace-authorization-${Date.now()}`;
+ const userId = `workspace-user-${Date.now()}`;
+ await initNamedSession(name);
+ const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId });
+
+ const { ws } = await openClientWs(name);
+ const subscribed = collectMessages(ws, {
+ until: (message) => message.type === "subscribed",
+ });
+ ws.send(JSON.stringify({ type: "subscribe", token, clientId: "workspace-client" }));
+
+ expect((await subscribed).some((message) => message.type === "subscribed")).toBe(true);
+ ws.close();
+ });
+
+ it("rejects a token for a suspended user", async () => {
+ const name = `ws-client-suspended-authorization-${Date.now()}`;
+ const userId = `suspended-user-${Date.now()}`;
+ await initNamedSession(name);
+ const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId });
+ await env.DB.prepare("UPDATE users SET suspended_at = ? WHERE id = ?")
+ .bind(Date.now(), userId)
+ .run();
+
+ const { ws } = await openClientWs(name);
+ const closed = new Promise<{ code: number }>((resolve) => {
+ ws.addEventListener("close", (event) => resolve({ code: event.code }));
+ });
+ ws.send(JSON.stringify({ type: "subscribe", token, clientId: "suspended-client" }));
+
+ await expect(closed).resolves.toEqual({ code: 4010 });
+ });
+
+ it("rejects a reconnect after collaborate permission is lost", async () => {
+ const name = `ws-client-lost-permission-${Date.now()}`;
+ const userId = `lost-permission-user-${Date.now()}`;
+ await initNamedSession(name);
+ const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId });
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_viewer' WHERE user_id = ?"
+ )
+ .bind(userId)
+ .run();
+
+ const { ws } = await openClientWs(name);
+ const closed = new Promise<{ code: number }>((resolve) => {
+ ws.addEventListener("close", (event) => resolve({ code: event.code }));
+ });
+ ws.send(JSON.stringify({ type: "subscribe", token, clientId: "lost-permission-client" }));
+
+ await expect(closed).resolves.toEqual({ code: 4010 });
+ });
+
+ it("rejects a token after its canonical user is removed", async () => {
+ const name = `ws-client-missing-user-${Date.now()}`;
+ const userId = `missing-user-${Date.now()}`;
+ await initNamedSession(name);
+ const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId });
+ await env.DB.batch([
+ env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?").bind(userId),
+ env.DB.prepare("DELETE FROM users WHERE id = ?").bind(userId),
+ ]);
+
+ const { ws } = await openClientWs(name);
+ const closed = new Promise<{ code: number }>((resolve) => {
+ ws.addEventListener("close", (event) => resolve({ code: event.code }));
+ });
+ ws.send(JSON.stringify({ type: "subscribe", token, clientId: "missing-user-client" }));
+
+ await expect(closed).resolves.toEqual({ code: 4010 });
+ });
+
+ it("rejects a token after the user's role assignment is removed", async () => {
+ const name = `ws-client-missing-assignment-${Date.now()}`;
+ const userId = `unassigned-user-${Date.now()}`;
+ await initNamedSession(name);
+ const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId });
+ await env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?").bind(userId).run();
+
+ const { ws } = await openClientWs(name);
+ const closed = new Promise<{ code: number }>((resolve) => {
+ ws.addEventListener("close", (event) => resolve({ code: event.code }));
+ });
+ ws.send(JSON.stringify({ type: "subscribe", token, clientId: "unassigned-client" }));
+
+ await expect(closed).resolves.toEqual({ code: 4010 });
+ });
+
it("subscribe includes batched replay with hasMore=false for empty session", async () => {
const name = `ws-client-replay-empty-${Date.now()}`;
await initNamedSession(name);
diff --git a/packages/control-plane/test/integration/ws-token-participants.test.ts b/packages/control-plane/test/integration/ws-token-participants.test.ts
index 24111887a..200c4fc16 100644
--- a/packages/control-plane/test/integration/ws-token-participants.test.ts
+++ b/packages/control-plane/test/integration/ws-token-participants.test.ts
@@ -1,6 +1,10 @@
import { describe, it, expect } from "vitest";
import { initSession, queryDO } from "./helpers";
+function wsTokenBody(body: Record): string {
+ return JSON.stringify({ canonicalUserId: "user-1", ...body });
+}
+
describe("POST /internal/ws-token", () => {
it("generates WS token for existing owner", async () => {
const { stub } = await initSession({ userId: "user-1", scmLogin: "testuser" });
@@ -8,7 +12,7 @@ describe("POST /internal/ws-token", () => {
const res = await stub.fetch("http://internal/internal/ws-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ userId: "user-1" }),
+ body: wsTokenBody({ userId: "user-1" }),
});
expect(res.status).toBe(200);
@@ -24,7 +28,11 @@ describe("POST /internal/ws-token", () => {
const res = await stub.fetch("http://internal/internal/ws-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ userId: "user-new", scmLogin: "newuser" }),
+ body: wsTokenBody({
+ userId: "user-new",
+ canonicalUserId: "user-new",
+ scmLogin: "newuser",
+ }),
});
expect(res.status).toBe(200);
@@ -46,7 +54,7 @@ describe("POST /internal/ws-token", () => {
await stub.fetch("http://internal/internal/ws-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ userId: "user-1" }),
+ body: wsTokenBody({ userId: "user-1" }),
});
const participants = await queryDO<{
@@ -54,7 +62,7 @@ describe("POST /internal/ws-token", () => {
ws_token_created_at: number | null;
}>(
stub,
- "SELECT ws_auth_token, ws_token_created_at FROM participants WHERE user_id = 'user-1'"
+ `SELECT ws_auth_token, ws_token_created_at FROM participants WHERE user_id = 'user-1'`
);
expect(participants[0].ws_auth_token).not.toBeNull();
@@ -69,7 +77,7 @@ describe("POST /internal/ws-token", () => {
const res = await stub.fetch("http://internal/internal/ws-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
+ body: wsTokenBody({}),
});
expect(res.status).toBe(400);
@@ -83,7 +91,7 @@ describe("POST /internal/ws-token", () => {
await stub.fetch("http://internal/internal/ws-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
+ body: wsTokenBody({
userId: "user-1",
scmLogin: "updated-login",
scmName: "Updated Name",
@@ -104,11 +112,11 @@ describe("GET /internal/participants", () => {
it("lists participants", async () => {
const { stub } = await initSession({ userId: "user-1", scmLogin: "testuser" });
- // Add a second participant
- await stub.fetch("http://internal/internal/participants", {
+ // WebSocket token issuance creates runtime participant identity.
+ await stub.fetch("http://internal/internal/ws-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ userId: "user-2", scmLogin: "user2" }),
+ body: wsTokenBody({ userId: "user-2", canonicalUserId: "user-2", scmLogin: "user2" }),
});
const res = await stub.fetch("http://internal/internal/participants");
@@ -129,27 +137,3 @@ describe("GET /internal/participants", () => {
expect(userIds).toContain("user-2");
});
});
-
-describe("POST /internal/participants", () => {
- it("adds participant", async () => {
- const { stub } = await initSession({ userId: "user-1" });
-
- const res = await stub.fetch("http://internal/internal/participants", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ userId: "user-added", scmLogin: "addeduser" }),
- });
-
- expect(res.status).toBe(200);
- const body = await res.json<{ id: string; status: string }>();
- expect(body.id).toEqual(expect.any(String));
- expect(body.status).toBe("added");
-
- const participants = await queryDO<{ user_id: string; role: string }>(
- stub,
- "SELECT user_id, role FROM participants WHERE user_id = 'user-added'"
- );
- expect(participants).toHaveLength(1);
- expect(participants[0].role).toBe("member");
- });
-});
diff --git a/packages/shared/src/types/sessions.ts b/packages/shared/src/types/sessions.ts
index 95560abee..940db8bf8 100644
--- a/packages/shared/src/types/sessions.ts
+++ b/packages/shared/src/types/sessions.ts
@@ -68,15 +68,6 @@ export type SpawnSource =
| "linear-bot"
| "slack-bot";
-export interface SessionParticipant {
- id: string;
- userId: string;
- scmLogin: string | null;
- scmName: string | null;
- scmEmail: string | null;
- role: ParticipantRole;
-}
-
/**
* Aggregate PR counts for a session, grouped by display status. Computed from
* the D1 session_pull_requests table for the session list; total = open +
From 9c9403d274d61f88e1516f6bed2428f309ddffd1 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Sun, 30 Aug 2026 21:52:32 -0700
Subject: [PATCH 4/9] feat: enforce automation ownership and execution
authority
---
.../automation/authorization-guard.test.ts | 52 ++++++
.../src/automation/authorization-guard.ts | 71 ++++++++
.../src/db/automation-store.test.ts | 7 +-
.../control-plane/src/db/automation-store.ts | 47 +++--
.../src/routes/automations.test.ts | 165 ++++++++++++++----
.../control-plane/src/routes/automations.ts | 123 +++++++++++--
.../src/scheduler/scheduler.test.ts | 127 ++++++++++++--
.../control-plane/src/scheduler/scheduler.ts | 158 ++++++++++++-----
.../automation-authorization.test.ts | 163 +++++++++++++++++
.../automation-invocations.test.ts | 66 ++++++-
.../test/integration/scheduler-events.test.ts | 9 +-
.../scheduler-slack-events.test.ts | 78 ++++++++-
.../test/integration/scheduler.test.ts | 99 +++++++++--
.../test/integration/webhooks-slack.test.ts | 13 +-
.../test/integration/webhooks.test.ts | 13 +-
packages/shared/src/types/automations.test.ts | 13 ++
packages/shared/src/types/automations.ts | 2 +
17 files changed, 1047 insertions(+), 159 deletions(-)
create mode 100644 packages/control-plane/src/automation/authorization-guard.test.ts
create mode 100644 packages/control-plane/src/automation/authorization-guard.ts
create mode 100644 packages/control-plane/test/integration/automation-authorization.test.ts
diff --git a/packages/control-plane/src/automation/authorization-guard.test.ts b/packages/control-plane/src/automation/authorization-guard.test.ts
new file mode 100644
index 000000000..a2d8c0ce0
--- /dev/null
+++ b/packages/control-plane/src/automation/authorization-guard.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import type { SqlDatabase } from "../db/sql-database";
+import { isAutomationExecutionAuthorized } from "./authorization-guard";
+
+function recordingDb(): { db: SqlDatabase; bindings: unknown[][]; queries: string[] } {
+ const bindings: unknown[][] = [];
+ const queries: string[] = [];
+ const statement = {
+ bind(...values: unknown[]) {
+ bindings.push(values);
+ return statement;
+ },
+ first: async () => ({ authorized: 1 }),
+ };
+ return {
+ db: {
+ prepare: (query: string) => {
+ queries.push(query);
+ return statement;
+ },
+ } as unknown as SqlDatabase,
+ bindings,
+ queries,
+ };
+}
+
+describe("automation execution authorization", () => {
+ it("queries owner and target-use permissions with stable bindings", async () => {
+ const { db, bindings, queries } = recordingDb();
+
+ await expect(
+ isAutomationExecutionAuthorized(db, "automation-1", ["sessions.collaborate"])
+ ).resolves.toBe(true);
+
+ expect(bindings).toHaveLength(1);
+ expect(bindings[0]?.[0]).toBe("automation-1");
+ expect(queries[0]).toContain("a.id = ? AND a.deleted_at IS NULL");
+ expect(queries[0]).toContain("automation_repositories");
+ expect(queries[0]).toContain("automation_environments");
+ });
+
+ it("authorizes an explicit execution user instead of the stored owner", async () => {
+ const { db, bindings, queries } = recordingDb();
+
+ await expect(
+ isAutomationExecutionAuthorized(db, "automation-1", [], "requester-1")
+ ).resolves.toBe(true);
+
+ expect(bindings[0]?.slice(0, 2)).toEqual(["requester-1", "automation-1"]);
+ expect(queries[0]).toContain("JOIN users u ON u.id = ?");
+ });
+});
diff --git a/packages/control-plane/src/automation/authorization-guard.ts b/packages/control-plane/src/automation/authorization-guard.ts
new file mode 100644
index 000000000..64d0d9366
--- /dev/null
+++ b/packages/control-plane/src/automation/authorization-guard.ts
@@ -0,0 +1,71 @@
+import { type PermissionId } from "@open-inspect/shared/rbac";
+import { rolePermissionPredicate } from "../authorization/permission-sql";
+import type { SqlDatabase } from "../db/sql-database";
+
+interface SqlPredicate {
+ sql: string;
+ values: readonly unknown[];
+}
+
+function executionPredicate(
+ automationId: string,
+ requiredAnyOf: readonly PermissionId[] = [],
+ executionUserId?: string
+): SqlPredicate {
+ const createGuard = rolePermissionPredicate("sessions.create");
+ const repositoryGuard = rolePermissionPredicate("repositories.use");
+ const environmentGuard = rolePermissionPredicate("environments.use");
+ const additionalGuards = requiredAnyOf.map(rolePermissionPredicate);
+ return {
+ sql: `EXISTS (
+ SELECT 1 FROM automations a
+ JOIN users u ON u.id = ${executionUserId ? "?" : "a.user_id"}
+ JOIN user_role_assignments ura ON ura.user_id = u.id
+ JOIN roles r ON r.id = ura.role_id
+ WHERE a.id = ? AND a.deleted_at IS NULL AND u.suspended_at IS NULL
+ AND ${createGuard.sql}
+ AND (
+ NOT EXISTS (SELECT 1 FROM automation_repositories ar WHERE ar.automation_id = a.id)
+ OR ${repositoryGuard.sql}
+ )
+ AND (
+ NOT EXISTS (SELECT 1 FROM automation_environments ae WHERE ae.automation_id = a.id)
+ OR ${environmentGuard.sql}
+ )
+ ${additionalGuards.length > 0 ? `AND (${additionalGuards.map((guard) => guard.sql).join(" OR ")})` : ""}
+ )`,
+ values: [
+ ...(executionUserId ? [executionUserId] : []),
+ automationId,
+ ...createGuard.values,
+ ...repositoryGuard.values,
+ ...environmentGuard.values,
+ ...additionalGuards.flatMap((guard) => guard.values),
+ ],
+ };
+}
+
+/**
+ * Revalidates that an automation's execution principal may create its session and use its targets.
+ *
+ * Scheduled and event runs default to the automation owner; manual runs pass the requester as
+ * `executionUserId`. `requiredAnyOf` adds source-specific execution requirements, such as session
+ * collaboration for Slack thread steering. Missing users, roles, automations, or suspended users
+ * fail closed.
+ *
+ * This does not decide whether a caller may manage or manually trigger the automation. The route's
+ * ownership-scoped authorization performs that admission before execution begins.
+ */
+export async function isAutomationExecutionAuthorized(
+ db: SqlDatabase,
+ automationId: string,
+ requiredAnyOf: readonly PermissionId[] = [],
+ executionUserId?: string
+): Promise {
+ const predicate = executionPredicate(automationId, requiredAnyOf, executionUserId);
+ const row = await db
+ .prepare(`SELECT CASE WHEN (${predicate.sql}) THEN 1 ELSE 0 END AS authorized`)
+ .bind(...predicate.values)
+ .first<{ authorized: number }>();
+ return row?.authorized === 1;
+}
diff --git a/packages/control-plane/src/db/automation-store.test.ts b/packages/control-plane/src/db/automation-store.test.ts
index 73ab7ec0d..fd4b8adca 100644
--- a/packages/control-plane/src/db/automation-store.test.ts
+++ b/packages/control-plane/src/db/automation-store.test.ts
@@ -91,7 +91,7 @@ const sampleRow: AutomationRow = {
next_run_at: now + 86400000,
consecutive_failures: 0,
created_by: "user-1",
- user_id: null,
+ user_id: "11111111111111111111111111111111",
created_at: now,
updated_at: now,
deleted_at: null,
@@ -152,6 +152,7 @@ describe("toAutomation", () => {
expect(automation.triggerConfig).toBeNull();
expect(automation.consecutiveFailures).toBe(0);
expect(automation.createdBy).toBe("user-1");
+ expect(automation.userId).toBe("11111111111111111111111111111111");
expect(automation.environmentIds).toEqual([]);
});
@@ -497,7 +498,9 @@ describe("AutomationStore", () => {
advanceSchedule: { fromSlot: now, nextRunAt: now + 60_000 },
});
- const advance = statements.at(-1)!;
+ const advance = statements.find((statement) =>
+ statement.sql.includes("SET next_run_at = ?")
+ )!;
// Compare-and-set on the claimed slot, not a monotonic timestamp guard:
// "any later value wins" lets a loser advance again from the winner's
// successor and skip a slot entirely.
diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts
index 8ecb0df39..45e6cbe41 100644
--- a/packages/control-plane/src/db/automation-store.ts
+++ b/packages/control-plane/src/db/automation-store.ts
@@ -206,6 +206,7 @@ export function toAutomation(
nextRunAt: row.next_run_at,
consecutiveFailures: row.consecutive_failures,
createdBy: row.created_by,
+ userId: row.user_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
deletedAt: row.deleted_at,
@@ -314,6 +315,7 @@ function toAutomationInvocation(
// ─── Store ───────────────────────────────────────────────────────────────────
+/** Persists automations, invocations, runs, and composable lifecycle mutations. */
export class AutomationStore {
constructor(private readonly db: SqlDatabase) {}
@@ -512,36 +514,48 @@ export class AutomationStore {
return this.getById(id);
}
- async softDelete(id: string): Promise {
- const now = Date.now();
- const result = await this.db
+ /** Build a soft-delete statement for composition in an atomic batch. */
+ bindSoftDelete(id: string, now = Date.now()): SqlStatement {
+ return this.db
.prepare(
"UPDATE automations SET deleted_at = ?, next_run_at = NULL, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
)
- .bind(now, now, id)
- .run();
+ .bind(now, now, id);
+ }
+
+ /** Soft-delete an automation and report whether a live row changed. */
+ async softDelete(id: string): Promise {
+ const result = await this.bindSoftDelete(id).run();
return (result.meta?.changes ?? 0) > 0;
}
- async pause(id: string): Promise {
- const now = Date.now();
- const result = await this.db
+ /** Build a pause statement for composition in an atomic batch. */
+ bindPause(id: string, now = Date.now()): SqlStatement {
+ return this.db
.prepare(
"UPDATE automations SET enabled = 0, next_run_at = NULL, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
)
- .bind(now, id)
- .run();
+ .bind(now, id);
+ }
+
+ /** Pause an automation and report whether a live row changed. */
+ async pause(id: string): Promise {
+ const result = await this.bindPause(id).run();
return (result.meta?.changes ?? 0) > 0;
}
- async resume(id: string, nextRunAt: number | null): Promise {
- const now = Date.now();
- const result = await this.db
+ /** Build a resume statement for composition in an atomic batch. */
+ bindResume(id: string, nextRunAt: number | null, now = Date.now()): SqlStatement {
+ return this.db
.prepare(
"UPDATE automations SET enabled = 1, next_run_at = ?, consecutive_failures = 0, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
)
- .bind(nextRunAt, now, id)
- .run();
+ .bind(nextRunAt, now, id);
+ }
+
+ /** Resume an automation and report whether a live row changed. */
+ async resume(id: string, nextRunAt: number | null): Promise {
+ const result = await this.bindResume(id, nextRunAt).run();
return (result.meta?.changes ?? 0) > 0;
}
@@ -855,7 +869,7 @@ export class AutomationStore {
/**
* Per-source overlap predicate, used both as the cheap pre-check and inside
- * the guarded insert (same SQL, one definition). Schedule/manual firings
+ * the conditional insert (same SQL, one definition). Schedule/manual firings
* block on ANY active run of the automation (main parity with
* getActiveRunForAutomation); event firings block per concurrency key only —
* an automation-wide guard would serialize unrelated events.
@@ -911,7 +925,6 @@ export class AutomationStore {
const invocation = params.invocation;
const overlap = this.overlapPredicate(invocation.automation_id, params.overlapScope);
const statements: SqlStatement[] = [];
-
statements.push(
this.db
.prepare(
diff --git a/packages/control-plane/src/routes/automations.test.ts b/packages/control-plane/src/routes/automations.test.ts
index 769ca4953..10bbbd760 100644
--- a/packages/control-plane/src/routes/automations.test.ts
+++ b/packages/control-plane/src/routes/automations.test.ts
@@ -13,14 +13,28 @@ import type { Principal } from "../auth/principal";
import type { SqlDatabase } from "../db/sql-database";
import type { Env } from "../types";
import { TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support";
-import { AutomationTriggerBlockedError } from "../scheduler/scheduler";
+import {
+ AutomationExecutionUnauthorizedError,
+ AutomationTriggerBlockedError,
+} from "../scheduler/scheduler";
+import { PERMISSION_IDS, type PermissionId } from "@open-inspect/shared/rbac";
const mockProviderAdapterGet = vi.hoisted(() => vi.fn());
+const mockResolveGitHubCredentialAuthority = vi.hoisted(() => vi.fn());
+const mockResolveGitHubEnrichmentForRequest = vi.hoisted(() => vi.fn());
vi.mock("../auth/model-provider-account-default-adapters", () => ({
modelProviderAccountAdapterRegistry: { get: mockProviderAdapterGet },
}));
+vi.mock("../source-control/github-credential-authority", () => ({
+ resolveGitHubCredentialAuthority: mockResolveGitHubCredentialAuthority,
+}));
+
+vi.mock("../session/identity", () => ({
+ resolveGitHubEnrichmentForRequest: mockResolveGitHubEnrichmentForRequest,
+}));
+
// ─── Mocks ──────────────────────────────────────────────────────────────────
const mockStore = {
@@ -38,6 +52,9 @@ const mockStore = {
getEnvironmentsForAutomationIds: vi.fn(),
bindAutomationInsert: vi.fn(),
bindAutomationUpdate: vi.fn(),
+ bindSoftDelete: vi.fn(),
+ bindPause: vi.fn(),
+ bindResume: vi.fn(),
bindRepositoryInserts: vi.fn(),
bindReplaceRepositories: vi.fn(),
bindEnvironmentInserts: vi.fn(),
@@ -85,8 +102,18 @@ const MockAutomationTriggerBlockedError = vi.hoisted(
}
}
);
+const MockAutomationExecutionUnauthorizedError = vi.hoisted(
+ () =>
+ class AutomationExecutionUnauthorizedError extends Error {
+ constructor() {
+ super("Automation owner is not authorized to execute");
+ this.name = "AutomationExecutionUnauthorizedError";
+ }
+ }
+);
vi.mock("../scheduler/scheduler", () => ({
+ AutomationExecutionUnauthorizedError: MockAutomationExecutionUnauthorizedError,
AutomationTriggerBlockedError: MockAutomationTriggerBlockedError,
Scheduler: vi.fn().mockImplementation(function () {
return { trigger: mockSchedulerTrigger };
@@ -142,17 +169,6 @@ vi.mock("./shared", async (importOriginal) => {
// ─── Helpers ────────────────────────────────────────────────────────────────
-/** Find the handler for a given method + path from automationRoutes. */
-function getHandler(method: string, path: string) {
- for (const route of automationRoutes) {
- if (route.method === method && route.pattern.test(path)) {
- const match = path.match(route.pattern)!;
- return { handler: route.handler, match };
- }
- }
- throw new Error(`No route found for ${method} ${path}`);
-}
-
function createEnv(): Env {
return {
DB: { batch: mockBatch } as unknown as D1Database,
@@ -178,21 +194,30 @@ const SLACK_BOT_PRINCIPAL: Principal = {
},
};
-function createCtx(principal: Principal = USER_PRINCIPAL): RequestContext {
+function createCtx(
+ principal: Principal = USER_PRINCIPAL,
+ permissions: readonly PermissionId[] = PERMISSION_IDS
+): RequestContext {
const statement = {
- bind: vi.fn(),
- first: vi.fn(async () => ({ active: 1 })),
+ bind: vi.fn(() => statement),
+ first: vi.fn(async () => ({ satisfied: 1 })),
+ all: vi.fn(async () => ({ results: [] })),
};
- statement.bind.mockReturnValue(statement);
-
return {
trace_id: "trace-1",
request_id: "req-1",
principal,
- db: {
- batch: mockBatch,
- prepare: vi.fn(() => statement),
- } as unknown as SqlDatabase,
+ ...(principal.kind === "user"
+ ? {
+ authorization: {
+ userId: principal.userId,
+ suspendedAt: null,
+ role: { id: "role_builtin_owner", key: "owner" as const, name: "Owner" },
+ permissions: [...permissions],
+ },
+ }
+ : {}),
+ db: { batch: mockBatch, prepare: vi.fn(() => statement) } as unknown as SqlDatabase,
executionCtx: TEST_BACKGROUND_TASK_CONTEXT,
metrics: {
d1Queries: [],
@@ -210,9 +235,14 @@ async function callRoute(
body?: unknown;
query?: Record;
principal?: Principal;
+ permissions?: readonly PermissionId[];
}
): Promise {
- const { handler, match } = getHandler(method, path);
+ const route = automationRoutes.find(
+ (candidate) => candidate.method === method && candidate.pattern.test(path)
+ );
+ if (!route) throw new Error(`No route found for ${method} ${path}`);
+ const match = path.match(route.pattern)!;
const url = new URL(`https://test.local${path}`);
if (options?.query) {
for (const [k, v] of Object.entries(options.query)) {
@@ -226,7 +256,20 @@ async function callRoute(
init.headers = { "Content-Type": "application/json" };
init.body = JSON.stringify(options.body);
}
- return handler(new Request(url, init), createEnv(), match, createCtx(options?.principal));
+ const ctx = createCtx(options?.principal, options?.permissions);
+ const automationRequirement =
+ route.authorization.kind === "active-user"
+ ? route.authorization.allOf.find((requirement) => requirement.kind === "automation")
+ : undefined;
+ if (automationRequirement) {
+ const automation = await mockStore.getById(
+ match.groups?.[automationRequirement.automationIdParam]
+ );
+ if (!automation)
+ return new Response(JSON.stringify({ error: "Automation not found" }), { status: 404 });
+ ctx.automationAdmission = { automation };
+ }
+ return route.handler(new Request(url, init), createEnv(), match, ctx);
}
// ─── Sample data ────────────────────────────────────────────────────────────
@@ -267,13 +310,16 @@ describe("automation route handlers", () => {
mockProviderAuthStore.listForAutomationIds.mockResolvedValue(new Map());
mockStore.bindAutomationInsert.mockReturnValue({ sql: "insert-automation" });
mockStore.bindAutomationUpdate.mockReturnValue({ sql: "update-automation" });
+ mockStore.bindSoftDelete.mockReturnValue({ sql: "delete-automation" });
+ mockStore.bindPause.mockReturnValue({ sql: "pause-automation" });
+ mockStore.bindResume.mockReturnValue({ sql: "resume-automation" });
mockStore.bindRepositoryInserts.mockReturnValue([{ sql: "insert-repositories" }]);
mockStore.bindReplaceRepositories.mockReturnValue([{ sql: "replace-repositories" }]);
mockStore.bindEnvironmentInserts.mockReturnValue([{ sql: "insert-environments" }]);
mockStore.bindReplaceEnvironments.mockReturnValue([{ sql: "replace-environments" }]);
mockProviderAuthStore.bindInserts.mockReturnValue([{ sql: "insert-provider-auth" }]);
mockProviderAuthStore.bindReplace.mockReturnValue([{ sql: "replace-provider-auth" }]);
- mockBatch.mockResolvedValue([]);
+ mockBatch.mockResolvedValue([{ meta: { changes: 1 }, results: [] }]);
mockSchedulerTrigger.mockResolvedValue({
invocationId: "inv-1",
runs: [{ id: "run-1" }],
@@ -286,6 +332,8 @@ describe("automation route handlers", () => {
archivedAt: null,
});
mockProviderAdapterGet.mockReturnValue({});
+ mockResolveGitHubCredentialAuthority.mockResolvedValue({ kind: "legacy" });
+ mockResolveGitHubEnrichmentForRequest.mockResolvedValue(null);
vi.mocked(resolveRepoOrError).mockResolvedValue({
repoId: 12345,
repoOwner: "acme",
@@ -1004,6 +1052,29 @@ describe("automation route handlers", () => {
});
describe("PUT /automations/:id (update)", () => {
+ it.each([
+ ["repository", { repositories: [] }, "repositories.use"],
+ ["environment", { environmentIds: [] }, "environments.use"],
+ ] as const)(
+ "requires target-use permission for %s replacement",
+ async (_target, body, permission) => {
+ mockStore.getById.mockResolvedValue(sampleRow);
+
+ const res = await callRoute("PUT", "/automations/auto-1", {
+ body,
+ permissions: PERMISSION_IDS.filter((candidate) => candidate !== permission),
+ });
+
+ expect(res.status).toBe(403);
+ await expect(res.json()).resolves.toEqual({
+ error: "Forbidden",
+ code: "permission_required",
+ permission,
+ });
+ expect(mockBatch).not.toHaveBeenCalled();
+ }
+ );
+
it("updates automation fields", async () => {
mockStore.getById.mockResolvedValue(sampleRow);
@@ -1560,8 +1631,6 @@ describe("automation route handlers", () => {
describe("DELETE /automations/:id", () => {
it("soft-deletes automation", async () => {
- mockStore.softDelete.mockResolvedValue(true);
-
const res = await callRoute("DELETE", "/automations/auto-1");
expect(res.status).toBe(200);
@@ -1570,7 +1639,7 @@ describe("automation route handlers", () => {
});
it("returns 404 when not found", async () => {
- mockStore.softDelete.mockResolvedValue(false);
+ mockBatch.mockResolvedValue([{ meta: { changes: 0 }, results: [] }]);
const res = await callRoute("DELETE", "/automations/missing");
expect(res.status).toBe(404);
@@ -1579,16 +1648,15 @@ describe("automation route handlers", () => {
describe("POST /automations/:id/pause", () => {
it("pauses automation", async () => {
- mockStore.pause.mockResolvedValue(true);
mockStore.getById.mockResolvedValue({ ...sampleRow, enabled: 0 });
const res = await callRoute("POST", "/automations/auto-1/pause");
expect(res.status).toBe(200);
- expect(mockStore.pause).toHaveBeenCalledWith("auto-1");
+ expect(mockStore.bindPause).toHaveBeenCalledWith("auto-1");
});
it("returns 404 when not found", async () => {
- mockStore.pause.mockResolvedValue(false);
+ mockBatch.mockResolvedValue([{ meta: { changes: 0 }, results: [] }]);
const res = await callRoute("POST", "/automations/missing/pause");
expect(res.status).toBe(404);
@@ -1598,11 +1666,10 @@ describe("automation route handlers", () => {
describe("POST /automations/:id/resume", () => {
it("resumes automation and recomputes next_run_at", async () => {
mockStore.getById.mockResolvedValue({ ...sampleRow, enabled: 0 });
- mockStore.resume.mockResolvedValue(true);
const res = await callRoute("POST", "/automations/auto-1/resume");
expect(res.status).toBe(200);
- expect(mockStore.resume).toHaveBeenCalledWith("auto-1", expect.any(Number));
+ expect(mockStore.bindResume).toHaveBeenCalledWith("auto-1", expect.any(Number));
});
it("returns 404 when not found", async () => {
@@ -1640,12 +1707,28 @@ describe("automation route handlers", () => {
expect(mockStore.update).not.toHaveBeenCalled();
}
);
+
+ it("returns 404 when the key update affects no current automation", async () => {
+ mockStore.getById.mockResolvedValue({ ...sampleRow, trigger_type: "webhook" });
+ mockBatch.mockResolvedValue([{ meta: { changes: 0 }, results: [] }]);
+
+ const res = await callRoute("POST", "/automations/auto-1/regenerate-key");
+
+ expect(res.status).toBe(404);
+ await expect(res.json()).resolves.toEqual({ error: "Automation not found" });
+ });
});
describe("POST /automations/:id/trigger", () => {
it("triggers automation via the scheduler", async () => {
mockStore.getById.mockResolvedValue(sampleRow);
mockStore.getActiveRunForAutomation.mockResolvedValue(null);
+ const enrichment = {
+ scmUserId: "123",
+ scmLogin: "requester",
+ accessTokenEncrypted: "encrypted-access",
+ };
+ mockResolveGitHubEnrichmentForRequest.mockResolvedValue(enrichment);
const res = await callRoute("POST", "/automations/auto-1/trigger");
expect(res.status).toBe(201);
@@ -1653,6 +1736,7 @@ describe("automation route handlers", () => {
invocationId: "inv-1",
runs: [{ id: "run-1" }],
});
+ expect(mockSchedulerTrigger).toHaveBeenCalledWith("auto-1", "user-1", enrichment);
});
it("returns 404 when automation not found", async () => {
@@ -1665,20 +1749,25 @@ describe("automation route handlers", () => {
it("returns 409 when the scheduler reports an active run", async () => {
mockStore.getById.mockResolvedValue(sampleRow);
- const env = createEnv();
mockSchedulerTrigger.mockRejectedValue(new AutomationTriggerBlockedError());
- const { handler, match } = getHandler("POST", "/automations/auto-1/trigger");
- const request = new Request("https://test.local/automations/auto-1/trigger", {
- method: "POST",
- });
- const res = await handler(request, env, match, createCtx());
+ const res = await callRoute("POST", "/automations/auto-1/trigger");
expect(res.status).toBe(409);
expect(await res.json()).toEqual({
error: "A run is already active for this automation",
});
});
+ it("returns 403 when the owner is unauthorized to execute", async () => {
+ mockStore.getById.mockResolvedValue(sampleRow);
+ mockSchedulerTrigger.mockRejectedValue(new AutomationExecutionUnauthorizedError());
+
+ const res = await callRoute("POST", "/automations/auto-1/trigger");
+
+ expect(res.status).toBe(403);
+ expect(await res.json()).toEqual({ error: "Execution authorization required" });
+ });
+
it("returns 500 when the scheduler cannot launch the automation", async () => {
mockStore.getById.mockResolvedValue(sampleRow);
mockSchedulerTrigger.mockRejectedValue(new Error("launch failed"));
diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts
index fc638ed7f..7536ee04a 100644
--- a/packages/control-plane/src/routes/automations.ts
+++ b/packages/control-plane/src/routes/automations.ts
@@ -19,6 +19,7 @@ import {
} from "@open-inspect/shared/types/automations";
import type { ModelProviderSelections } from "@open-inspect/shared/types/provider-accounts";
import { listChannels } from "@open-inspect/shared/slack";
+import type { PermissionId } from "@open-inspect/shared/rbac";
import {
getValidModelOrDefault,
isValidModel,
@@ -48,7 +49,11 @@ import { generateId } from "../auth/crypto";
import { applyIdentityEnforcement, resolveCanonicalUserId } from "../auth/identity-enforcement";
import { generateWebhookApiKey, hashApiKey, encryptSentrySecret } from "../auth/webhook-key";
import { createLogger } from "../logger";
-import { AutomationTriggerBlockedError, Scheduler } from "../scheduler/scheduler";
+import {
+ AutomationExecutionUnauthorizedError,
+ AutomationTriggerBlockedError,
+ Scheduler,
+} from "../scheduler/scheduler";
import { hydrateAutomation } from "../automation/hydrate";
import { MAX_AUTOMATION_REPOSITORIES } from "@open-inspect/shared/types/automations";
import {
@@ -63,14 +68,40 @@ import {
resolveRepoOrError,
requireAutomation,
requirePermission,
+ type AutomationRouteAdmission,
} from "./shared";
import type { Env } from "../types";
import type { SqlDatabase, SqlStatement } from "../db/sql-database";
import { z } from "zod";
import { ProviderAccountSelectionPolicyError } from "../model-provider-accounts/selection-policy";
+import { resolveGitHubCredentialAuthority } from "../source-control/github-credential-authority";
+import { resolveGitHubEnrichmentForRequest } from "../session/identity";
const logger = createLogger("router:automations");
+function requireTargetPermissions(
+ ctx: RequestContext,
+ requiredPermissions: readonly PermissionId[]
+): Response | null {
+ const authorization = ctx.authorization;
+ if (!authorization) return json({ error: "Authorization unavailable" }, 503);
+ const missingPermission = requiredPermissions.find(
+ (permission) => !authorization.permissions.includes(permission)
+ );
+ if (missingPermission) {
+ return json(
+ { error: "Forbidden", code: "permission_required", permission: missingPermission },
+ 403
+ );
+ }
+ return null;
+}
+
+function admittedAutomation(ctx: RequestContext): AutomationRouteAdmission {
+ if (!ctx.automationAdmission) throw new Error("Missing automation route admission");
+ return ctx.automationAdmission;
+}
+
/** Minimum cron interval in minutes. */
const MIN_CRON_INTERVAL_MINUTES = 15;
@@ -568,6 +599,13 @@ async function handleCreateAutomation(
if (e instanceof TargetSelectionError) return error(e.message, 400);
throw e;
}
+ if (ctx.principal?.kind === "user") {
+ const targetAuthorizationError = requireTargetPermissions(ctx, [
+ ...(requestedRepositories.length > 0 ? (["repositories.use"] as const) : []),
+ ...(requestedEnvironmentIds.length > 0 ? (["environments.use"] as const) : []),
+ ]);
+ if (targetAuthorizationError) return targetAuthorizationError;
+ }
const isSchedule = triggerType === "schedule";
@@ -709,7 +747,7 @@ async function handleCreateAutomation(
...slackStore.bindChannelStatements(row.id, extractSlackChannels(body.triggerConfig))
);
}
- await db.batch(createStatements);
+ await ctx.db.batch(createStatements);
const automation = await hydrateAutomation(db, (await store.getById(id))!);
@@ -776,8 +814,8 @@ async function handleUpdateAutomation(
const db: SqlDatabase = ctx.db;
const store = new AutomationStore(db);
const providerAuthStore = new AutomationModelProviderAuthStore(db);
- const existing = await store.getById(id);
- if (!existing) return error("Automation not found", 404);
+ const admission = admittedAutomation(ctx);
+ const { automation: existing } = admission;
const rawBody = await parseJsonBody(request);
if (rawBody instanceof Response) return rawBody;
@@ -876,6 +914,14 @@ async function handleUpdateAutomation(
// it simply applies from the next invocation.
const selection = getRepositorySelection(body);
const environmentSelection = getEnvironmentSelection(body);
+ const requiredTargetPermissions: PermissionId[] = [
+ ...(selection.kind === "replace" ? (["repositories.use"] as const) : []),
+ ...(environmentSelection.kind === "replace" ? (["environments.use"] as const) : []),
+ ];
+ if (requiredTargetPermissions.length > 0) {
+ const targetAuthorizationError = requireTargetPermissions(ctx, requiredTargetPermissions);
+ if (targetAuthorizationError) return targetAuthorizationError;
+ }
// The count rules span both selections, so when EITHER is replaced they are
// validated against the automation's FINAL state (the replacement plus the
@@ -1038,7 +1084,7 @@ async function handleUpdateAutomation(
);
}
if (statements.length > 0) {
- await db.batch(statements);
+ await ctx.db.batch(statements);
}
const updated = await store.getById(id);
if (!updated) return error("Automation not found", 404);
@@ -1063,7 +1109,9 @@ async function handleDeleteAutomation(
if (!id) return error("Automation ID required", 400);
const store = new AutomationStore(ctx.db);
- const deleted = await store.softDelete(id);
+ admittedAutomation(ctx);
+ const result = await ctx.db.batch([store.bindSoftDelete(id)]);
+ const deleted = result[0]?.meta.changes === 1;
if (!deleted) return error("Automation not found", 404);
logger.info("automation.deleted", {
@@ -1086,7 +1134,9 @@ async function handlePauseAutomation(
if (!id) return error("Automation ID required", 400);
const store = new AutomationStore(ctx.db);
- const paused = await store.pause(id);
+ admittedAutomation(ctx);
+ const result = await ctx.db.batch([store.bindPause(id)]);
+ const paused = result[0]?.meta.changes === 1;
if (!paused) return error("Automation not found", 404);
logger.info("automation.paused", {
@@ -1112,8 +1162,7 @@ async function handleResumeAutomation(
if (!id) return error("Automation ID required", 400);
const store = new AutomationStore(ctx.db);
- const existing = await store.getById(id);
- if (!existing) return error("Automation not found", 404);
+ const { automation: existing } = admittedAutomation(ctx);
// For schedule automations, compute the next run time.
// For event-driven automations, resume with null next_run_at.
@@ -1127,7 +1176,8 @@ async function handleResumeAutomation(
nextRunAt = null;
}
- const resumed = await store.resume(id, nextRunAt);
+ const result = await ctx.db.batch([store.bindResume(id, nextRunAt)]);
+ const resumed = result[0]?.meta.changes === 1;
if (!resumed) return error("Automation not found", 404);
logger.info("automation.resumed", {
@@ -1145,7 +1195,7 @@ async function handleResumeAutomation(
}
async function handleTriggerAutomation(
- _request: Request,
+ request: Request,
env: Env,
match: RegExpMatchArray,
ctx: RequestContext
@@ -1153,14 +1203,37 @@ async function handleTriggerAutomation(
const id = match.groups?.id;
if (!id) return error("Automation ID required", 400);
- const store = new AutomationStore(ctx.db);
- const automation = await store.getById(id);
- if (!automation) return error("Automation not found", 404);
+ admittedAutomation(ctx);
+ const requesterUserId = ctx.authorization?.userId;
+ if (!requesterUserId) return error("Authorization unavailable", 503);
+
+ let requesterEnrichment;
+ try {
+ requesterEnrichment = await resolveGitHubEnrichmentForRequest(
+ env,
+ ctx.db,
+ new UserStore(ctx.db),
+ requesterUserId,
+ await resolveGitHubCredentialAuthority(ctx, request.headers)
+ );
+ } catch (enrichmentError) {
+ logger.warn("Failed to enrich manual automation trigger with GitHub identity", {
+ error:
+ enrichmentError instanceof Error ? enrichmentError : new Error(String(enrichmentError)),
+ automation_id: id,
+ request_id: ctx.request_id,
+ trace_id: ctx.trace_id,
+ });
+ }
// The scheduler performs the authoritative D1-backed concurrency check.
let triggerResult;
try {
- triggerResult = await new Scheduler(ctx.db, env, ctx.executionCtx).trigger(id);
+ triggerResult = await new Scheduler(ctx.db, env, ctx.executionCtx).trigger(
+ id,
+ requesterUserId,
+ requesterEnrichment ?? undefined
+ );
} catch (triggerError) {
logger.error("automation.trigger_failed", {
event: "automation.trigger_failed",
@@ -1172,6 +1245,9 @@ async function handleTriggerAutomation(
if (triggerError instanceof AutomationTriggerBlockedError) {
return error("A run is already active for this automation", 409);
}
+ if (triggerError instanceof AutomationExecutionUnauthorizedError) {
+ return json({ error: "Execution authorization required" }, 403);
+ }
return error("Failed to trigger automation", 500);
}
@@ -1242,8 +1318,7 @@ async function handleRegenerateKey(
if (!id) return error("Automation ID required", 400);
const store = new AutomationStore(ctx.db);
- const automation = await store.getById(id);
- if (!automation) return error("Automation not found", 404);
+ const { automation } = admittedAutomation(ctx);
const workerUrl = env.WORKER_URL || "";
@@ -1262,7 +1337,12 @@ async function handleRegenerateKey(
parsedBody.data.sentryClientSecret,
env.REPO_SECRETS_ENCRYPTION_KEY
);
- await store.update(id, { trigger_auth_data: encrypted } as Record);
+ const statement = store.bindAutomationUpdate(id, {
+ trigger_auth_data: encrypted,
+ } as Record);
+ if (!statement) return error("Automation not found", 404);
+ const result = await ctx.db.batch([statement]);
+ if ((result[0]?.meta.changes ?? 0) === 0) return error("Automation not found", 404);
logger.info("automation.secret_updated", {
event: "automation.secret_updated",
@@ -1284,7 +1364,12 @@ async function handleRegenerateKey(
const apiKey = generateWebhookApiKey();
const hash = await hashApiKey(apiKey);
- await store.update(id, { trigger_auth_data: hash } as Record);
+ const statement = store.bindAutomationUpdate(id, {
+ trigger_auth_data: hash,
+ } as Record);
+ if (!statement) return error("Automation not found", 404);
+ const result = await ctx.db.batch([statement]);
+ if ((result[0]?.meta.changes ?? 0) === 0) return error("Automation not found", 404);
logger.info("automation.key_regenerated", {
event: "automation.key_regenerated",
diff --git a/packages/control-plane/src/scheduler/scheduler.test.ts b/packages/control-plane/src/scheduler/scheduler.test.ts
index 66071cf5b..9aa22805d 100644
--- a/packages/control-plane/src/scheduler/scheduler.test.ts
+++ b/packages/control-plane/src/scheduler/scheduler.test.ts
@@ -20,6 +20,7 @@ const mockResolveSessionProviderAuth = vi.hoisted(() =>
{ provider: "xai", authMode: "api_key", selectionSource: "unattended_policy" },
])
);
+const mockIsAutomationExecutionAuthorized = vi.hoisted(() => vi.fn().mockResolvedValue(true));
vi.mock("../source-control", () => ({
createSourceControlProviderFromEnv: vi.fn(() => ({
@@ -31,6 +32,14 @@ vi.mock("../session/provider-account-resolution", () => ({
resolveSessionProviderAuth: mockResolveSessionProviderAuth,
}));
+vi.mock("../automation/authorization-guard", async (importOriginal) => {
+ const actual = (await importOriginal()) as Record;
+ return {
+ ...actual,
+ isAutomationExecutionAuthorized: mockIsAutomationExecutionAuthorized,
+ };
+});
+
vi.mock("../session/skill-resolution", () => ({
resolveManagedSkills: vi.fn(async () => ({
selection: { mode: "all" },
@@ -41,7 +50,7 @@ vi.mock("../session/skill-resolution", () => ({
})),
}));
-const { Scheduler } = await import("./scheduler");
+const { AutomationExecutionUnauthorizedError, Scheduler } = await import("./scheduler");
// ─── Mock factories ──────────────────────────────────────────────────────────
@@ -202,6 +211,7 @@ function createEmptyDbMock(): D1Database {
prepare: vi.fn(() => ({
bind: vi.fn(() => ({
first: vi.fn(async () => null),
+ run: vi.fn(async () => undefined),
})),
})),
} as unknown as D1Database;
@@ -349,7 +359,7 @@ const sampleAutomation = {
next_run_at: now - 60000,
consecutive_failures: 0,
created_by: "user-1",
- user_id: null as string | null,
+ user_id: "user-1" as string | null,
created_at: now - 86400000,
updated_at: now - 86400000,
deleted_at: null,
@@ -475,6 +485,7 @@ describe("Scheduler", () => {
{ provider: "xai", authMode: "api_key", selectionSource: "unattended_policy" },
]);
mockProviderAuthList.mockResolvedValue([]);
+ mockIsAutomationExecutionAuthorized.mockResolvedValue(true);
capturedInvocationParams = [];
mockStore = createMockStore();
mockGetSlackAutomationsForChannel.mockResolvedValue([]);
@@ -488,7 +499,8 @@ describe("Scheduler", () => {
describe("tick", () => {
it("returns empty summary when no overdue automations", async () => {
- const scheduler = createScheduler();
+ const env = createEnv();
+ const scheduler = createScheduler(env);
const result = await scheduler.tick();
expect(result).toEqual({ processed: 0, skipped: 0, failed: 0 });
@@ -498,7 +510,9 @@ describe("Scheduler", () => {
mockStore.getOverdueAutomations.mockResolvedValue([sampleAutomation]);
selectRepositories("auto-1", [repositoryRow("auto-1")]);
- const scheduler = createScheduler();
+ const env = createEnv();
+ const fetchMock = vi.mocked(env.SESSION.get(env.SESSION.idFromName("auto-1")).fetch);
+ const scheduler = createScheduler(env);
const result = await scheduler.tick();
expect(result).toMatchObject({ processed: 1 });
@@ -522,6 +536,33 @@ describe("Scheduler", () => {
expect.any(String),
expect.any(Number)
);
+ await expect(getInitBody(fetchMock)).resolves.toMatchObject({
+ userId: sampleAutomation.created_by,
+ canonicalUserId: sampleAutomation.user_id,
+ });
+ await expect(getPromptBody(fetchMock)).resolves.toMatchObject({
+ authorId: sampleAutomation.created_by,
+ canonicalUserId: sampleAutomation.user_id,
+ });
+ });
+
+ it("rejects unattended execution before invocation work when the owner is unauthorized", async () => {
+ mockStore.getOverdueAutomations.mockResolvedValue([sampleAutomation]);
+ selectRepositories("auto-1", [repositoryRow("auto-1")]);
+ mockIsAutomationExecutionAuthorized.mockResolvedValue(false);
+
+ const result = await createScheduler().tick();
+
+ expect(result).toEqual({ processed: 0, skipped: 0, failed: 1 });
+ expect(mockIsAutomationExecutionAuthorized).toHaveBeenCalledWith(
+ expect.anything(),
+ "auto-1",
+ [],
+ "user-1"
+ );
+ expect(mockStore.getActiveRunForAutomation).not.toHaveBeenCalled();
+ expect(mockStore.insertInvocationGuarded).not.toHaveBeenCalled();
+ expect(mockResolveSessionProviderAuth).not.toHaveBeenCalled();
});
it("does not enqueue a prompt when recovery wins the launch transition", async () => {
@@ -1329,8 +1370,8 @@ describe("Scheduler", () => {
);
});
- it("falls back to identity lookup for legacy automations without user_id", async () => {
- mockStore.getOverdueAutomations.mockResolvedValue([sampleAutomation]);
+ it("repairs legacy automation identity before invocation admission", async () => {
+ mockStore.getOverdueAutomations.mockResolvedValue([{ ...sampleAutomation, user_id: null }]);
selectRepositories("auto-1", [repositoryRow("auto-1")]);
mockUserStoreGetIdentity.mockResolvedValue({ userId: "looked-up-user" });
@@ -1338,22 +1379,23 @@ describe("Scheduler", () => {
await scheduler.tick();
expect(mockUserStoreGetIdentity).toHaveBeenCalledWith("github", "user-1");
+ expect(mockUserStoreGetIdentity.mock.invocationCallOrder[0]).toBeLessThan(
+ mockIsAutomationExecutionAuthorized.mock.invocationCallOrder[0]
+ );
expect(mockSessionStoreCreate).toHaveBeenCalledWith(
expect.objectContaining({ userId: "looked-up-user" })
);
});
- it("creates session with null userId when identity lookup finds nothing", async () => {
- mockStore.getOverdueAutomations.mockResolvedValue([sampleAutomation]);
+ it("rejects a legacy automation when identity lookup finds nothing", async () => {
+ mockStore.getOverdueAutomations.mockResolvedValue([{ ...sampleAutomation, user_id: null }]);
selectRepositories("auto-1", [repositoryRow("auto-1")]);
mockUserStoreGetIdentity.mockResolvedValue(null);
- const scheduler = createScheduler();
- await scheduler.tick();
+ const result = await createScheduler().tick();
- expect(mockSessionStoreCreate).toHaveBeenCalledWith(
- expect.objectContaining({ userId: null })
- );
+ expect(result).toEqual({ processed: 0, skipped: 0, failed: 1 });
+ expect(mockSessionStoreCreate).not.toHaveBeenCalled();
});
it("swallows launch-failure tracking errors and logs scheduler.fail_track_error", async () => {
@@ -2011,7 +2053,9 @@ describe("Scheduler", () => {
mockStore.getById.mockResolvedValue(null);
const scheduler = createScheduler();
- await expect(scheduler.trigger("nonexistent")).rejects.toThrow("Automation not found");
+ await expect(scheduler.trigger("nonexistent", "user-1")).rejects.toThrow(
+ "Automation not found"
+ );
});
it("rejects when active run exists, recording nothing", async () => {
@@ -2019,18 +2063,42 @@ describe("Scheduler", () => {
mockStore.getActiveRunForAutomation.mockResolvedValue({ id: "run-active" });
const scheduler = createScheduler();
- await expect(scheduler.trigger("auto-1")).rejects.toThrow("An active run already exists");
+ await expect(scheduler.trigger("auto-1", "user-1")).rejects.toThrow(
+ "An active run already exists"
+ );
expect(mockStore.insertSkippedInvocation).not.toHaveBeenCalled();
expect(mockStore.insertInvocationGuarded).not.toHaveBeenCalled();
});
+ it("rejects with a purpose-specific error when the owner cannot execute", async () => {
+ mockStore.getById.mockResolvedValue(sampleAutomation);
+ mockIsAutomationExecutionAuthorized.mockResolvedValue(false);
+
+ const scheduler = createScheduler();
+ await expect(scheduler.trigger("auto-1", "user-1")).rejects.toBeInstanceOf(
+ AutomationExecutionUnauthorizedError
+ );
+ expect(mockStore.getActiveRunForAutomation).not.toHaveBeenCalled();
+ expect(mockStore.insertInvocationGuarded).not.toHaveBeenCalled();
+ });
+
it("creates an invocation and launches runs on successful trigger", async () => {
mockStore.getById.mockResolvedValue(sampleAutomation);
mockStore.getActiveRunForAutomation.mockResolvedValue(null);
mockStore.getRepositoriesForAutomation.mockResolvedValue([repositoryRow("auto-1")]);
- const scheduler = createScheduler();
- const result = await scheduler.trigger("auto-1");
+ const env = createEnv();
+ const fetchMock = vi.mocked(env.SESSION.get(env.SESSION.idFromName("auto-1")).fetch);
+ const scheduler = createScheduler(env);
+ const result = await scheduler.trigger("auto-1", "user-1", {
+ scmUserId: "123",
+ scmLogin: "requester",
+ displayName: "Requester",
+ email: "123+requester@users.noreply.github.com",
+ accessTokenEncrypted: "encrypted-access",
+ refreshTokenEncrypted: "encrypted-refresh",
+ tokenExpiresAt: 123456,
+ });
expect(result).toEqual({
invocationId: expect.any(String),
@@ -2048,6 +2116,15 @@ describe("Scheduler", () => {
expect.any(String),
expect.any(Number)
);
+ await expect(getInitBody(fetchMock)).resolves.toMatchObject({
+ scmUserId: "123",
+ scmLogin: "requester",
+ scmName: "Requester",
+ scmEmail: "123+requester@users.noreply.github.com",
+ scmTokenEncrypted: "encrypted-access",
+ scmRefreshTokenEncrypted: "encrypted-refresh",
+ scmTokenExpiresAt: 123456,
+ });
});
it("rejects when every launch fails, still recording the failed children", async () => {
@@ -2071,7 +2148,9 @@ describe("Scheduler", () => {
.spyOn((scheduler as unknown as { log: Logger }).log, "error")
.mockImplementation(() => {});
- await expect(scheduler.trigger("auto-1")).rejects.toThrow("Failed to trigger automation");
+ await expect(scheduler.trigger("auto-1", "user-1")).rejects.toThrow(
+ "Failed to trigger automation"
+ );
const failTrackCall = errorSpy.mock.calls.find(
([, data]) =>
@@ -2441,7 +2520,9 @@ describe("Scheduler", () => {
mockStore.getLatestSteerableRunForThread.mockResolvedValue(null);
mockStore.getActiveRunForKey.mockResolvedValue(null);
- const scheduler = createScheduler();
+ const env = createEnv();
+ const fetchMock = vi.mocked(env.SESSION.get(env.SESSION.idFromName("auto-slack")).fetch);
+ const scheduler = createScheduler(env);
// Matching text so the trigger conditions pass.
const result = await scheduler.event(makeSlackEvent());
@@ -2464,6 +2545,14 @@ describe("Scheduler", () => {
automation_id: "auto-slack",
status: "starting",
});
+ await expect(getInitBody(fetchMock)).resolves.toMatchObject({
+ userId: sampleSlackAutomation.created_by,
+ canonicalUserId: sampleSlackAutomation.user_id,
+ });
+ await expect(getPromptBody(fetchMock)).resolves.toMatchObject({
+ authorId: sampleSlackAutomation.created_by,
+ canonicalUserId: sampleSlackAutomation.user_id,
+ });
});
it("appends workspace session instructions to a new Slack automation session", async () => {
diff --git a/packages/control-plane/src/scheduler/scheduler.ts b/packages/control-plane/src/scheduler/scheduler.ts
index de07e6351..a037d1c3b 100644
--- a/packages/control-plane/src/scheduler/scheduler.ts
+++ b/packages/control-plane/src/scheduler/scheduler.ts
@@ -70,8 +70,10 @@ import { resolveManagedSkills } from "../session/skill-resolution";
import type { EnqueuePromptRequest } from "../session/enqueue-prompt-contract";
import { resolveAutomationRepositories } from "../automation/repository";
import { resolveAutomationSessionTarget } from "../automation/session-target";
+import { isAutomationExecutionAuthorized } from "../automation/authorization-guard";
import type { RequestContext } from "../routes/shared";
import { deliverWithRetry } from "../session/callback-delivery";
+import type { GitHubEnrichment } from "../session/identity";
/** Max automations to process per tick (backpressure). */
const MAX_PER_TICK = 25;
@@ -188,9 +190,20 @@ export class AutomationTriggerBlockedError extends Error {
}
}
+/** Raised when an automation's execution principal lacks required authorization. */
+export class AutomationExecutionUnauthorizedError extends Error {
+ /** Create an error for an unauthorized automation execution principal. */
+ constructor() {
+ super("Automation execution principal is not authorized");
+ this.name = "AutomationExecutionUnauthorizedError";
+ }
+}
+
interface StartInvocationParams {
automation: AutomationRow;
source: AutomationInvocationSource;
+ /** Human authority used for a manual firing; unattended sources use the automation owner. */
+ executionPrincipal?: ExecutionPrincipal;
/** Cron slot being served — becomes scheduled_at and the idempotency key (schedule source only). */
scheduledAt?: number;
/** Next cron slot, advanced atomically with the insert (schedule source only). */
@@ -215,6 +228,12 @@ interface StartInvocationParams {
instructionsOverrideFactory?: () => Promise;
}
+interface ExecutionPrincipal {
+ platformUserId: string;
+ participantUserId: string;
+ scmEnrichment?: GitHubEnrichment;
+}
+
type StartInvocationResult =
/** Invocation inserted; children launched (some may have pre-failed). */
| { outcome: "started"; invocationId: string; runs: AutomationRunRow[]; launched: number }
@@ -255,6 +274,7 @@ export function composeAutomationPrompt(contextBlock: string, instructions: stri
return `${instructions}\n---\n\n${contextBlock}`;
}
+/** Coordinates authorized automation scheduling, dispatch, and completion handling. */
export class Scheduler {
private readonly log: Logger;
@@ -323,7 +343,37 @@ export class Scheduler {
store: AutomationStore,
params: StartInvocationParams
): Promise {
- const { automation, source } = params;
+ const { source } = params;
+ let automation = params.automation;
+ if (!automation.user_id && automation.created_by && automation.created_by !== "anonymous") {
+ const identity = await new UserStore(this.db).getIdentity("github", automation.created_by);
+ if (identity) {
+ await this.db
+ .prepare(`UPDATE automations SET user_id = ? WHERE id = ? AND user_id IS NULL`)
+ .bind(identity.userId, automation.id)
+ .run();
+ automation = { ...automation, user_id: identity.userId };
+ }
+ }
+ const executionPrincipal =
+ params.executionPrincipal ??
+ (automation.user_id
+ ? {
+ platformUserId: automation.user_id,
+ participantUserId: automation.created_by,
+ }
+ : null);
+ if (
+ !executionPrincipal ||
+ !(await isAutomationExecutionAuthorized(
+ this.db,
+ automation.id,
+ [],
+ executionPrincipal.platformUserId
+ ))
+ ) {
+ throw new AutomationExecutionUnauthorizedError();
+ }
const now = Date.now();
const concurrencyKey = params.concurrencyKey ?? null;
@@ -335,7 +385,7 @@ export class Scheduler {
? { kind: "concurrencyKey", concurrencyKey }
: { kind: "automation" };
- // Cheap pre-check; the guarded insert below re-applies the same predicate
+ // Cheap pre-check; the conditional insert below re-applies the same predicate
// atomically, so a race here only costs a wasted child build.
const activeRun =
overlapScope.kind === "concurrencyKey"
@@ -406,7 +456,7 @@ export class Scheduler {
const launchCandidates = children.filter((child) => child.status === "starting");
// Resolve provider routing before admission, alongside the already-built
// target children. Together these values are the immutable launch snapshot
- // for this firing: edits made after the guarded insert cannot change which
+ // for this firing: edits made after the conditional insert cannot change which
// account an admitted child uses.
let providerAuthSnapshot:
| { providerAuth: SessionModelProviderAuthInput[] }
@@ -501,9 +551,16 @@ export class Scheduler {
automation,
child,
providerAuthSnapshot.providerAuth,
- sessionId
+ sessionId,
+ executionPrincipal
+ );
+ await this.sendPromptToSession(
+ sessionId,
+ automation,
+ child.id,
+ executionPrincipal,
+ instructionsOverride
);
- await this.sendPromptToSession(sessionId, automation, child.id, instructionsOverride);
child.status = "running";
child.session_id = sessionId;
} catch (e) {
@@ -862,7 +919,7 @@ export class Scheduler {
// ─── Event handler ───────────────────────────────────────────────────────
- /** Match an inbound event to automations and start or steer their invocations. */
+ /** Match an inbound event to authorized automations and start or steer their invocations. */
async event(event: AutomationEvent): Promise {
const store = new AutomationStore(this.db);
@@ -938,9 +995,32 @@ export class Scheduler {
event.concurrencyKey,
now - SLACK_THREAD_CONTINUITY_WINDOW_MS
);
- if (steerable?.session_id && (await this.steerSession(steerable, automation, event))) {
- steered++;
- continue;
+ if (steerable?.session_id) {
+ let ownerAuthorized: boolean;
+ try {
+ ownerAuthorized = await isAutomationExecutionAuthorized(this.db, automation.id, [
+ "sessions.collaborate",
+ ]);
+ } catch (error) {
+ this.log.warn("Failed to authorize automation owner for slack steering", {
+ event: "scheduler.slack_steer_authorization_failed",
+ automation_id: automation.id,
+ error: error instanceof Error ? error : new Error(String(error)),
+ });
+ continue;
+ }
+ if (!ownerAuthorized) {
+ this.log.warn("Blocked slack steering for unauthorized automation owner", {
+ event: "scheduler.slack_steer_unauthorized",
+ automation_id: automation.id,
+ session_id: steerable.session_id,
+ });
+ continue;
+ }
+ if (await this.steerSession(steerable, automation, event)) {
+ steered++;
+ continue;
+ }
}
// No steerable session (outside the window, no session yet, or a rare
// enqueue error) → fall through. Like the @mention path's stale-session
@@ -1027,14 +1107,27 @@ export class Scheduler {
// ─── Manual trigger ──────────────────────────────────────────────────────
- async trigger(automationId: string): Promise {
+ /** Manually trigger an automation under the requesting user's authority. */
+ async trigger(
+ automationId: string,
+ requesterUserId: string,
+ requesterEnrichment?: GitHubEnrichment
+ ): Promise {
const store = new AutomationStore(this.db);
const automation = await store.getById(automationId);
if (!automation) {
throw new Error("Automation not found");
}
- const result = await this.startInvocation(store, { automation, source: "manual" });
+ const result = await this.startInvocation(store, {
+ automation,
+ source: "manual",
+ executionPrincipal: {
+ platformUserId: requesterUserId,
+ participantUserId: requesterUserId,
+ scmEnrichment: requesterEnrichment,
+ },
+ });
if (result.outcome !== "started") {
// Manual overlap (pre-check or lost race) records nothing.
@@ -1303,28 +1396,9 @@ export class Scheduler {
automation: AutomationRow,
run: AutomationRunRow,
providerAuth: SessionModelProviderAuthInput[],
- sessionId: string
+ sessionId: string,
+ executionPrincipal: ExecutionPrincipal
): Promise {
- // Resolve the canonical user_id for the session index.
- // Automations created through the web UI populate user_id at creation time
- // (handleCreateAutomation resolves it for both GitHub and Google users), so this
- // lookup is skipped for them. The fallback below only covers legacy rows with
- // user_id = NULL: those predate Google login and store the GitHub numeric user ID
- // in created_by (from the canonical browser principal), so a GitHub-only identity lookup
- // recovers the canonical user. It becomes dead code once legacy rows are backfilled.
- let userId = automation.user_id;
- if (!userId && automation.created_by && automation.created_by !== "anonymous") {
- try {
- const userStore = new UserStore(this.db);
- const identity = await userStore.getIdentity("github", automation.created_by);
- if (identity) {
- userId = identity.userId;
- }
- } catch {
- // Best-effort — proceed without user_id
- }
- }
-
const ctx: RequestContext = {
trace_id: `automation:${automation.id}`,
request_id: run.id,
@@ -1361,7 +1435,7 @@ export class Scheduler {
environmentId: target.environmentId,
},
{ mode: "all" },
- userId
+ executionPrincipal.platformUserId
);
const sessionInput: SessionInitInput = {
@@ -1370,10 +1444,15 @@ export class Scheduler {
title: `[Auto] ${automation.name}`,
model: automation.model,
reasoningEffort: automation.reasoning_effort,
- participantUserId: automation.created_by,
- platformUserId: userId,
- scmTokenEncrypted: null,
- scmRefreshTokenEncrypted: null,
+ participantUserId: executionPrincipal.participantUserId,
+ platformUserId: executionPrincipal.platformUserId,
+ scmUserId: executionPrincipal.scmEnrichment?.scmUserId,
+ scmLogin: executionPrincipal.scmEnrichment?.scmLogin,
+ scmName: executionPrincipal.scmEnrichment?.displayName,
+ scmEmail: executionPrincipal.scmEnrichment?.email,
+ scmTokenEncrypted: executionPrincipal.scmEnrichment?.accessTokenEncrypted ?? null,
+ scmRefreshTokenEncrypted: executionPrincipal.scmEnrichment?.refreshTokenEncrypted ?? null,
+ scmTokenExpiresAt: executionPrincipal.scmEnrichment?.tokenExpiresAt,
codeServerEnabled,
vncEnabled,
sandboxSettings,
@@ -1392,6 +1471,7 @@ export class Scheduler {
sessionId: string,
automation: AutomationRow,
runId: string,
+ executionPrincipal: ExecutionPrincipal,
instructionsOverride?: string
): Promise {
const callbackContext: AutomationCallbackContext = {
@@ -1403,8 +1483,8 @@ export class Scheduler {
await this.enqueueSessionPrompt(sessionId, {
content: instructionsOverride ?? automation.instructions,
- authorId: automation.created_by,
- canonicalUserId: automation.user_id,
+ authorId: executionPrincipal.participantUserId,
+ canonicalUserId: executionPrincipal.platformUserId,
source: "automation",
callbackContext,
});
diff --git a/packages/control-plane/test/integration/automation-authorization.test.ts b/packages/control-plane/test/integration/automation-authorization.test.ts
new file mode 100644
index 000000000..089a904fa
--- /dev/null
+++ b/packages/control-plane/test/integration/automation-authorization.test.ts
@@ -0,0 +1,163 @@
+import { env } from "cloudflare:test";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { AutomationStore, type AutomationRow } from "../../src/db/automation-store";
+import { UserStore } from "../../src/db/user-store";
+import { cleanD1Tables } from "./cleanup";
+import { serviceFetch, sqlDatabase } from "./helpers";
+
+const BROWSER_USER_ID = "11111111111111111111111111111111";
+
+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,
+ };
+}
+
+async function seedBrowser(role: "owner" | "member" = "member"): Promise {
+ const response = await serviceFetch("https://cp.test/me/authorization", {
+ initialUserRole: role,
+ });
+ expect(response.status).toBe(200);
+}
+
+describe("automation router authorization", () => {
+ beforeEach(cleanD1Tables);
+ afterEach(cleanD1Tables);
+
+ it("returns 404 for a missing automation before authority disclosure", async () => {
+ await seedBrowser("member");
+
+ const response = await serviceFetch("https://cp.test/automations/missing", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Updated" }),
+ initialUserRole: "member",
+ });
+
+ expect(response.status).toBe(404);
+ await expect(response.json()).resolves.toEqual({ error: "Automation not found" });
+
+ const store = new AutomationStore(env.DB);
+ await store.create(automation("deleted-automation", BROWSER_USER_ID));
+ await store.softDelete("deleted-automation");
+ const deleted = await serviceFetch("https://cp.test/automations/deleted-automation", {
+ method: "DELETE",
+ initialUserRole: "member",
+ });
+ expect(deleted.status).toBe(404);
+ await expect(deleted.json()).resolves.toEqual({ error: "Automation not found" });
+ });
+
+ it("allows own management and denies another user's automation", async () => {
+ await seedBrowser("member");
+ const other = await new UserStore(sqlDatabase(env.DB)).createUser({ displayName: "Other" });
+ const store = new AutomationStore(env.DB);
+ await store.create(automation("own-automation", BROWSER_USER_ID));
+ await store.create(automation("other-automation", other.id));
+
+ const own = await serviceFetch("https://cp.test/automations/own-automation", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Updated own" }),
+ initialUserRole: "member",
+ });
+ const denied = await serviceFetch("https://cp.test/automations/other-automation", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Updated other" }),
+ initialUserRole: "member",
+ });
+
+ expect(own.status).toBe(200);
+ expect(denied.status).toBe(403);
+ await expect(denied.json()).resolves.toMatchObject({
+ code: "permission_required",
+ permission: "automations.manage.own",
+ });
+ expect((await store.getById("other-automation"))?.name).toBe("other-automation");
+ });
+
+ it("denies users without manage permission", async () => {
+ await seedBrowser("member");
+ await new AutomationStore(env.DB).create(automation("viewer-target", BROWSER_USER_ID));
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_viewer' WHERE user_id = ?"
+ )
+ .bind(BROWSER_USER_ID)
+ .run();
+
+ const response = await serviceFetch("https://cp.test/automations/viewer-target", {
+ method: "DELETE",
+ });
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({
+ code: "permission_required",
+ permission: "automations.manage.own",
+ });
+ });
+
+ it("retains body-derived target permission checks after route admission", async () => {
+ await seedBrowser("member");
+ await new AutomationStore(env.DB).create(automation("target-permission", BROWSER_USER_ID));
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO roles
+ (id, key, name, normalized_name, description, is_system)
+ VALUES ('role_manage_only', NULL, 'Manage Only', 'manage only', NULL, 0)`
+ ),
+ env.DB.prepare(
+ `INSERT INTO role_permissions (role_id, permission_id)
+ VALUES ('role_manage_only', 'automations.manage.own')`
+ ),
+ env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_manage_only' WHERE user_id = ?"
+ ).bind(BROWSER_USER_ID),
+ ]);
+
+ const response = await serviceFetch("https://cp.test/automations/target-permission", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ repositories: [] }),
+ });
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({
+ code: "permission_required",
+ permission: "repositories.use",
+ });
+ });
+
+ it("denies bot services before handler dispatch", async () => {
+ await seedBrowser("owner");
+ await new AutomationStore(env.DB).create(automation("service-target", BROWSER_USER_ID));
+
+ const response = await serviceFetch("https://cp.test/automations/service-target", {
+ method: "DELETE",
+ service: "slack-bot",
+ actor: "slack:U-AUTOMATION",
+ });
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({ code: "service_capability_required" });
+ expect(await new AutomationStore(env.DB).getById("service-target")).not.toBeNull();
+ });
+});
diff --git a/packages/control-plane/test/integration/automation-invocations.test.ts b/packages/control-plane/test/integration/automation-invocations.test.ts
index 5e0a1bbe5..018ee0d07 100644
--- a/packages/control-plane/test/integration/automation-invocations.test.ts
+++ b/packages/control-plane/test/integration/automation-invocations.test.ts
@@ -8,6 +8,7 @@ import {
type AutomationRow,
type AutomationRunRow,
} from "../../src/db/automation-store";
+import { isAutomationExecutionAuthorized } from "../../src/automation/authorization-guard";
import { cleanD1Tables } from "./cleanup";
// ─── Fixtures ────────────────────────────────────────────────────────────────
@@ -27,7 +28,7 @@ function makeAutomation(overrides?: Partial): AutomationRow {
next_run_at: now + 86_400_000,
consecutive_failures: 0,
created_by: "user-1",
- user_id: null,
+ user_id: "user-1",
created_at: now,
updated_at: now,
deleted_at: null,
@@ -90,7 +91,68 @@ async function countRows(table: string, where = "1=1"): Promise {
}
describe("automation invocations (D1 integration)", () => {
- beforeEach(cleanD1Tables);
+ beforeEach(async () => {
+ await cleanD1Tables();
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO users
+ (id, display_name, email, email_verified, avatar_url, created_at, updated_at)
+ VALUES ('user-1', 'Execution Owner', NULL, 0, NULL, 1, 1)`
+ ),
+ env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = 'user-1'"
+ ),
+ ]);
+ });
+
+ it("checks owner and target-use permissions for unattended execution", async () => {
+ const ownerId = "11111111111111111111111111111111";
+ const roleId = "role_execution_test";
+ const store = new AutomationStore(env.DB);
+ const automation = makeAutomation({ user_id: ownerId, created_by: ownerId });
+
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO users
+ (id, display_name, email, email_verified, avatar_url, created_at, updated_at)
+ VALUES (?, 'Execution Owner', NULL, 0, NULL, 1, 1)`
+ ).bind(ownerId),
+ env.DB.prepare(
+ `INSERT INTO roles
+ (id, key, name, normalized_name, description, is_system)
+ VALUES (?, NULL, 'Execution Test', 'execution test', NULL, 0)`
+ ).bind(roleId),
+ env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?").bind(
+ roleId,
+ ownerId
+ ),
+ ]);
+ await store.create(automation);
+ await Promise.all(
+ [
+ ...store.bindRepositoryInserts(
+ automation.id,
+ [{ repo_owner: "acme", repo_name: "app", repo_id: 1, base_branch: "main" }],
+ Date.now()
+ ),
+ ...store.bindEnvironmentInserts(automation.id, ["env_1"], Date.now()),
+ ].map((statement) => statement.run())
+ );
+
+ const authorized = () => isAutomationExecutionAuthorized(env.DB, automation.id);
+ const grant = (permission: string) =>
+ env.DB.prepare("INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)")
+ .bind(roleId, permission)
+ .run();
+
+ await expect(authorized()).resolves.toBe(false);
+ await grant("sessions.create");
+ await expect(authorized()).resolves.toBe(false);
+ await grant("repositories.use");
+ await expect(authorized()).resolves.toBe(false);
+ await grant("environments.use");
+ await expect(authorized()).resolves.toBe(true);
+ });
// ─── Derived status ────────────────────────────────────────────────────────
diff --git a/packages/control-plane/test/integration/scheduler-events.test.ts b/packages/control-plane/test/integration/scheduler-events.test.ts
index ab406235b..4c48e04ea 100644
--- a/packages/control-plane/test/integration/scheduler-events.test.ts
+++ b/packages/control-plane/test/integration/scheduler-events.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest";
import { env } from "cloudflare:test";
-import { sqlDatabase } from "./helpers";
+import { seedActiveUser, sqlDatabase } from "./helpers";
import { AutomationStore, type AutomationRow } from "../../src/db/automation-store";
import type { SentryAutomationEvent, WebhookAutomationEvent } from "@open-inspect/shared/triggers";
import { cleanD1Tables } from "./cleanup";
@@ -23,7 +23,7 @@ function makeAutomation(overrides?: Partial): AutomationRow {
next_run_at: now + 86400000,
consecutive_failures: 0,
created_by: "user-1",
- user_id: null,
+ user_id: "user-1",
created_at: now,
updated_at: now,
deleted_at: null,
@@ -74,7 +74,10 @@ function makeWebhookEvent(
}
describe("Scheduler event handling (integration)", () => {
- beforeEach(cleanD1Tables);
+ beforeEach(async () => {
+ await cleanD1Tables();
+ await seedActiveUser("user-1");
+ });
// ─── Sentry event matching ───────────────────────────────────────────────
diff --git a/packages/control-plane/test/integration/scheduler-slack-events.test.ts b/packages/control-plane/test/integration/scheduler-slack-events.test.ts
index 08632bfdc..a87a38fc2 100644
--- a/packages/control-plane/test/integration/scheduler-slack-events.test.ts
+++ b/packages/control-plane/test/integration/scheduler-slack-events.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest";
import { env } from "cloudflare:test";
-import { sqlDatabase } from "./helpers";
+import { seedActiveUser, sqlDatabase } from "./helpers";
import { AutomationStore, type AutomationRow } from "../../src/db/automation-store";
import { SlackChannelStore } from "../../src/db/slack-channel-store";
import type { SlackAutomationEvent } from "@open-inspect/shared/triggers";
@@ -24,7 +24,7 @@ function makeAutomation(overrides?: Partial): AutomationRow {
next_run_at: null,
consecutive_failures: 0,
created_by: "user-1",
- user_id: null,
+ user_id: "user-1",
created_at: now,
updated_at: now,
deleted_at: null,
@@ -80,7 +80,10 @@ async function fetchInvocations(store: AutomationStore, automationId: string) {
}
describe("Scheduler slack event handling (integration)", () => {
- beforeEach(cleanD1Tables);
+ beforeEach(async () => {
+ await cleanD1Tables();
+ await seedActiveUser("user-1");
+ });
it("triggers a matching slack automation and records thread coordinates", async () => {
const store = new AutomationStore(env.DB);
@@ -174,7 +177,7 @@ describe("Scheduler slack event handling (integration)", () => {
expect(JSON.parse(invocationRow!.trigger_metadata!).channel).toBe("C1");
});
- it("steers the running session on a follow-up reply instead of dropping it", async () => {
+ it("steers the running session when the automation owner remains authorized", async () => {
const store = new AutomationStore(env.DB);
const id = await seedSlackAutomation(store);
@@ -203,6 +206,73 @@ describe("Scheduler slack event handling (integration)", () => {
).toBeUndefined();
});
+ it.each([
+ [
+ "suspended",
+ async () => {
+ await env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?").bind("user-1").run();
+ },
+ ],
+ [
+ "revoked",
+ async () => {
+ await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?")
+ .bind("role_builtin_viewer", "user-1")
+ .run();
+ },
+ ],
+ [
+ "missing collaboration permission",
+ async () => {
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO roles
+ (id, key, name, normalized_name, description, is_system)
+ VALUES ('role_no_collaboration', NULL, 'No Collaboration', 'no collaboration',
+ NULL, 0)`
+ ),
+ env.DB.prepare(
+ `INSERT INTO role_permissions (role_id, permission_id)
+ VALUES ('role_no_collaboration', 'sessions.create')`
+ ),
+ env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_no_collaboration' WHERE user_id = 'user-1'"
+ ),
+ ]);
+ },
+ ],
+ ])(
+ "does not steer when the automation owner's execution authority is %s",
+ async (authorityState, revoke) => {
+ const store = new AutomationStore(env.DB);
+ const id = await seedSlackAutomation(store);
+ const concurrencyKey = `slack:C1:thread-${authorityState}`;
+ expect(
+ await sendEvent(
+ makeSlackEvent({
+ text: "deploy the api",
+ concurrencyKey,
+ triggerKey: `slack:msg:C1:root-${authorityState}`,
+ })
+ )
+ ).toMatchObject({ triggered: 1 });
+
+ await revoke();
+
+ expect(
+ await sendEvent(
+ makeSlackEvent({
+ text: "also update the changelog",
+ concurrencyKey,
+ triggerKey: `slack:msg:C1:reply-${authorityState}`,
+ })
+ )
+ ).toEqual({ triggered: 0, skipped: 0, steered: 0 });
+ expect(await fetchRuns(id)).toHaveLength(1);
+ expect(await fetchInvocations(store, id)).toHaveLength(1);
+ }
+ );
+
it("continues the same session on a reply after the run has completed", async () => {
const store = new AutomationStore(env.DB);
const id = await seedSlackAutomation(store);
diff --git a/packages/control-plane/test/integration/scheduler.test.ts b/packages/control-plane/test/integration/scheduler.test.ts
index 702430ea7..21b465158 100644
--- a/packages/control-plane/test/integration/scheduler.test.ts
+++ b/packages/control-plane/test/integration/scheduler.test.ts
@@ -1,11 +1,15 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { env } from "cloudflare:test";
-import { sqlDatabase } from "./helpers";
+import { seedActiveUser, sqlDatabase } from "./helpers";
import { AutomationStore, type AutomationRow } from "../../src/db/automation-store";
import type { AutomationRunStatus } from "@open-inspect/shared/types/automations";
import { cleanD1Tables } from "./cleanup";
import { makeRunRow, seedRun, fetchRuns } from "./run-helpers";
-import { Scheduler, resolveAutomationProviderAuth } from "../../src/scheduler/scheduler";
+import {
+ AutomationExecutionUnauthorizedError,
+ Scheduler,
+ resolveAutomationProviderAuth,
+} from "../../src/scheduler/scheduler";
import { AutomationModelProviderAuthStore } from "../../src/db/automation-model-provider-auth";
import { ModelProviderAccountStore } from "../../src/db/model-provider-accounts";
import { ProviderDefaultStore } from "../../src/db/provider-account-defaults";
@@ -30,7 +34,7 @@ function makeAutomation(overrides?: Partial): AutomationRow {
next_run_at: now + 86400000,
consecutive_failures: 0,
created_by: "user-1",
- user_id: null,
+ user_id: "user-1",
created_at: now,
updated_at: now,
deleted_at: null,
@@ -44,6 +48,7 @@ function makeAutomation(overrides?: Partial): AutomationRow {
describe("Scheduler (integration)", () => {
beforeEach(async () => {
await cleanD1Tables();
+ await seedActiveUser("user-1");
await env.DB.exec(
"DELETE FROM model_provider_account_defaults; DELETE FROM model_provider_accounts;"
);
@@ -527,8 +532,8 @@ describe("Scheduler (integration)", () => {
];
const [triggerA, triggerB, tick] = await Promise.allSettled([
- schedulers[0]!.trigger("auto-concurrent-admission"),
- schedulers[1]!.trigger("auto-concurrent-admission"),
+ schedulers[0]!.trigger("auto-concurrent-admission", "user-1"),
+ schedulers[1]!.trigger("auto-concurrent-admission", "user-1"),
schedulers[2]!.tick(),
]);
@@ -629,7 +634,7 @@ describe("Scheduler (integration)", () => {
});
it("rejects when automation is not found", async () => {
- await expect(createScheduler().trigger("nonexistent")).rejects.toThrow(
+ await expect(createScheduler().trigger("nonexistent", "user-1")).rejects.toThrow(
"Automation not found"
);
});
@@ -648,14 +653,85 @@ describe("Scheduler (integration)", () => {
})
);
- await expect(createScheduler().trigger("auto-trig1")).rejects.toThrow(
+ await expect(createScheduler().trigger("auto-trig1", "user-1")).rejects.toThrow(
"An active run already exists"
);
});
- it("creates a run record when triggered", async () => {
+ it("requires the requester to have session execution authority", async () => {
+ const requesterId = "manual-trigger-requester";
+ await seedActiveUser(requesterId);
+ const roleId = "role_manual_trigger_only";
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO roles (id, key, name, normalized_name, description, is_system)
+ VALUES (?, NULL, 'Manual Trigger Only', 'manual trigger only', NULL, 0)`
+ ).bind(roleId),
+ env.DB.prepare(
+ `INSERT INTO role_permissions (role_id, permission_id)
+ VALUES (?, 'automations.trigger.any')`
+ ).bind(roleId),
+ env.DB.prepare(`UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?`).bind(
+ roleId,
+ requesterId
+ ),
+ ]);
const store = new AutomationStore(env.DB);
- await store.create(makeAutomation({ id: "auto-trig2" }));
+ await store.create(makeAutomation({ id: "auto-trigger-only" }));
+
+ await expect(
+ createScheduler().trigger("auto-trigger-only", requesterId)
+ ).rejects.toBeInstanceOf(AutomationExecutionUnauthorizedError);
+ expect(await fetchRuns("auto-trigger-only")).toEqual([]);
+ });
+
+ it("creates manual-trigger sessions as the requester", async () => {
+ const requesterId = "manual-trigger-requester";
+ await seedActiveUser(requesterId);
+ const store = new AutomationStore(env.DB);
+ await store.create(makeAutomation({ id: "auto-requester-principal" }));
+ const promptBodies: Array> = [];
+ const sessionFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const request = input instanceof Request ? input : new Request(input, init);
+ const path = new URL(request.url).pathname;
+ if (path === "/internal/init") return Response.json({ status: "ok" });
+ if (path === "/internal/prompt") {
+ promptBodies.push(await request.json>());
+ return Response.json({ messageId: "msg-requester", status: "queued" });
+ }
+ return new Response("Not Found", { status: 404 });
+ });
+ const schedulerEnv = {
+ ...(env as Env),
+ SESSION: {
+ idFromName: vi.fn((name: string) => name),
+ get: vi.fn(() => ({ fetch: sessionFetch })),
+ } as unknown as DurableObjectNamespace,
+ };
+
+ await createScheduler(schedulerEnv).trigger("auto-requester-principal", requesterId);
+
+ expect(
+ await env.DB.prepare(
+ `SELECT user_id FROM sessions WHERE automation_id = 'auto-requester-principal'`
+ ).first()
+ ).toEqual({ user_id: requesterId });
+ expect(promptBodies).toContainEqual(
+ expect.objectContaining({ authorId: requesterId, canonicalUserId: requesterId })
+ );
+ });
+
+ it("repairs a legacy owner before creating a triggered run", async () => {
+ const store = new AutomationStore(env.DB);
+ await env.DB.prepare(
+ `INSERT INTO user_identities
+ (id, user_id, provider, provider_user_id, provider_issuer, created_at, updated_at)
+ VALUES ('legacy-identity', 'user-1', 'github', 'legacy-github-id',
+ 'https://github.com', 1, 1)`
+ ).run();
+ await store.create(
+ makeAutomation({ id: "auto-trig2", created_by: "legacy-github-id", user_id: null })
+ );
const sessionFetch = vi.fn(async (input: RequestInfo | URL) => {
const path = new URL(
@@ -675,7 +751,7 @@ describe("Scheduler (integration)", () => {
} as unknown as DurableObjectNamespace,
};
- const result = await createScheduler(schedulerEnv).trigger("auto-trig2");
+ const result = await createScheduler(schedulerEnv).trigger("auto-trig2", "user-1");
expect(result).toEqual({
invocationId: expect.any(String),
runs: [expect.objectContaining({ status: "running" })],
@@ -684,13 +760,14 @@ describe("Scheduler (integration)", () => {
const runs = await fetchRuns("auto-trig2");
expect(runs).toHaveLength(1);
expect(runs[0]!.invocation_id).not.toBeNull();
+ expect((await store.getById("auto-trig2"))!.user_id).toBe("user-1");
});
});
// ─── Invocation finalization (D2) ─────────────────────────────────────────
describe("invocation finalization", () => {
- /** Seed an invocation with N children in the given statuses via the real guarded insert. */
+ /** Seed an invocation with N children in the given statuses via the real conditional insert. */
async function seedInvocation(
store: AutomationStore,
automationId: string,
diff --git a/packages/control-plane/test/integration/webhooks-slack.test.ts b/packages/control-plane/test/integration/webhooks-slack.test.ts
index 0036b4be8..9bde8b843 100644
--- a/packages/control-plane/test/integration/webhooks-slack.test.ts
+++ b/packages/control-plane/test/integration/webhooks-slack.test.ts
@@ -7,6 +7,8 @@ import { serviceFetch, sqlDatabase } from "./helpers";
// ─── Helpers ──────────────────────────────────────────────────────────────────
+const AUTOMATION_OWNER_ID = "11111111111111111111111111111111";
+
function makeSlackEventBody(overrides?: Record): Record {
const ts = `${Date.now()}.${Math.floor(Math.random() * 1e6)}`;
return {
@@ -38,8 +40,8 @@ function makeSlackAutomation(overrides?: Partial): AutomationRow
enabled: 1,
next_run_at: null,
consecutive_failures: 0,
- created_by: "user-1",
- user_id: null,
+ created_by: AUTOMATION_OWNER_ID,
+ user_id: AUTOMATION_OWNER_ID,
created_at: now,
updated_at: now,
deleted_at: null,
@@ -56,6 +58,13 @@ function makeSlackAutomation(overrides?: Partial): AutomationRow
}
async function seedSlackAutomation(): Promise {
+ await env.DB.prepare(
+ `INSERT INTO users
+ (id, display_name, email, email_verified, avatar_url, created_at, updated_at)
+ VALUES (?, 'Slack Owner', NULL, 0, NULL, ?, ?)`
+ )
+ .bind(AUTOMATION_OWNER_ID, Date.now(), Date.now())
+ .run();
const store = new AutomationStore(env.DB);
const automation = makeSlackAutomation();
await store.create(automation);
diff --git a/packages/control-plane/test/integration/webhooks.test.ts b/packages/control-plane/test/integration/webhooks.test.ts
index dfb9427e0..288f0da4f 100644
--- a/packages/control-plane/test/integration/webhooks.test.ts
+++ b/packages/control-plane/test/integration/webhooks.test.ts
@@ -5,6 +5,7 @@ import { hashApiKey } from "../../src/auth/webhook-key";
import { encryptToken } from "../../src/auth/crypto";
import { cleanD1Tables } from "./cleanup";
import { fetchRuns } from "./run-helpers";
+import { seedActiveUser } from "./helpers";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -36,7 +37,7 @@ function makeAutomation(overrides: Partial = {}): AutomationRow {
next_run_at: null,
consecutive_failures: 0,
created_by: "test-user",
- user_id: null,
+ user_id: "test-user",
created_at: Date.now(),
updated_at: Date.now(),
deleted_at: null,
@@ -134,7 +135,10 @@ const sentryMetricWarningPayload = {
// ─── Sentry webhook tests (per-automation) ───────────────────────────────────
describe("POST /webhooks/sentry/:id", () => {
- beforeEach(cleanD1Tables);
+ beforeEach(async () => {
+ await cleanD1Tables();
+ await seedActiveUser("test-user");
+ });
it("creates an automation run for a current Sentry issue.created webhook", async () => {
const automation = await createSentryAutomation();
@@ -382,7 +386,10 @@ describe("POST /webhooks/sentry/:id", () => {
// ─── Automation webhook tests ─────────────────────────────────────────────────
describe("POST /webhooks/automation/:id", () => {
- beforeEach(cleanD1Tables);
+ beforeEach(async () => {
+ await cleanD1Tables();
+ await seedActiveUser("test-user");
+ });
const TEST_API_KEY = "test-webhook-api-key-abc123";
diff --git a/packages/shared/src/types/automations.test.ts b/packages/shared/src/types/automations.test.ts
index 101de3180..c752c742f 100644
--- a/packages/shared/src/types/automations.test.ts
+++ b/packages/shared/src/types/automations.test.ts
@@ -6,6 +6,7 @@ import {
} from "./automations";
const ACCOUNT_ID = "0123456789abcdef0123456789abcdef";
+const USER_ID = "11111111111111111111111111111111";
const automation = {
id: "auto-1",
@@ -20,6 +21,7 @@ const automation = {
nextRunAt: 123,
consecutiveFailures: 0,
createdBy: "user-1",
+ userId: USER_ID,
createdAt: 1,
updatedAt: 2,
deletedAt: null,
@@ -67,6 +69,17 @@ describe("listAutomationsResponseSchema", () => {
).toBe(false);
});
+ it("requires a canonical owner ID when ownership is present", () => {
+ const response = (userId: string | null) => ({
+ automations: [{ ...automation, userId }],
+ hasMore: false as const,
+ nextCursor: null,
+ });
+
+ expect(listAutomationsResponseSchema.safeParse(response(null)).success).toBe(true);
+ expect(listAutomationsResponseSchema.safeParse(response("user-1")).success).toBe(false);
+ });
+
it("validates recent execution summaries", () => {
const result = listAutomationsResponseSchema.parse({
automations: [
diff --git a/packages/shared/src/types/automations.ts b/packages/shared/src/types/automations.ts
index 36cf8ff29..c14dbcc31 100644
--- a/packages/shared/src/types/automations.ts
+++ b/packages/shared/src/types/automations.ts
@@ -8,6 +8,7 @@ import {
import type { RepositoryInput, RepositoryRef } from "./repositories";
import { modelProviderSelectionsSchema } from "./provider-accounts";
import { isEnvironmentId } from "./environments";
+import { isCanonicalUserId } from "../user-id";
export type AutomationRunStatus = "starting" | "running" | "completed" | "failed" | "skipped";
@@ -80,6 +81,7 @@ const automationSchema = z.object({
nextRunAt: z.number().nullable(),
consecutiveFailures: z.number(),
createdBy: z.string(),
+ userId: z.string().refine(isCanonicalUserId, "Invalid canonical user ID").nullable(),
createdAt: z.number(),
updatedAt: z.number(),
deletedAt: z.number().nullable(),
From 1333903431f06c4b8faf23d50701a300f6471a3d Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Sun, 30 Aug 2026 21:55:29 -0700
Subject: [PATCH 5/9] feat: add workspace access administration
---
packages/control-plane/src/routes/rbac.ts | 81 ++-
.../test/integration/rbac-routes.test.ts | 560 ++++++++++++++++++
.../web/src/app/(app)/settings/page.test.tsx | 54 +-
packages/web/src/app/(app)/settings/page.tsx | 65 +-
.../web/src/app/api/me/authorization/route.ts | 6 +
.../app/api/members/[userId]/role/route.ts | 6 +
.../app/api/members/[userId]/status/route.ts | 6 +
packages/web/src/app/api/members/route.ts | 3 +
packages/web/src/app/api/roles/[id]/route.ts | 6 +
packages/web/src/app/api/roles/route.ts | 3 +
.../src/components/app-auth-boundary.test.tsx | 40 ++
.../web/src/components/app-auth-boundary.tsx | 30 +
.../automations/automations-list.test.tsx | 1 +
.../components/global-command-menu.test.tsx | 44 +-
.../src/components/global-command-menu.tsx | 40 +-
.../environment-integration-settings.tsx | 60 +-
.../settings/environments-settings.tsx | 220 ++++---
.../settings/images-settings.test.tsx | 24 +
.../components/settings/images-settings.tsx | 28 +-
.../commit-signing-settings.test.tsx | 4 +
.../integrations/commit-signing-settings.tsx | 10 +-
.../enablement-integration-settings.test.tsx | 25 +
.../enablement-integration-settings.tsx | 33 +-
.../github-integration-settings.test.tsx | 4 +
.../github-integration-settings.tsx | 35 +-
.../linear-integration-settings.tsx | 31 +-
.../slack-integration-settings.test.tsx | 4 +
.../slack-integration-settings.tsx | 33 +-
.../settings/mcp-servers-settings.test.tsx | 20 +
.../settings/mcp-servers-settings.tsx | 55 +-
.../provider-accounts-settings.test.tsx | 21 +
.../settings/provider-accounts-settings.tsx | 286 ++++-----
.../settings/sandbox-settings.test.tsx | 4 +
.../components/settings/sandbox-settings.tsx | 16 +-
.../components/settings/scm-settings.test.tsx | 4 +
.../src/components/settings/scm-settings.tsx | 24 +-
.../settings/secrets-settings.test.tsx | 110 ++++
.../components/settings/secrets-settings.tsx | 66 ++-
.../components/settings/settings-nav.test.tsx | 59 ++
.../src/components/settings/settings-nav.tsx | 7 +-
.../settings/settings-registry.test.ts | 43 ++
.../components/settings/settings-registry.ts | 183 +++++-
.../settings/settings-shell.test.tsx | 30 +-
.../components/settings/settings-shell.tsx | 32 +-
.../settings/skills-settings/index.test.tsx | 59 ++
.../settings/skills-settings/index.tsx | 38 +-
.../skills-settings/profiles.test.tsx | 2 +-
.../settings/skills-settings/profiles.tsx | 24 +-
.../skills-settings/skills-catalog.test.tsx | 6 +-
.../skills-settings/skills-catalog.tsx | 46 +-
.../settings/workspace-settings.test.tsx | 129 ++++
.../settings/workspace-settings.tsx | 146 +++++
.../web/src/hooks/use-automations.test.tsx | 1 +
.../use-current-user-authorization.test.tsx | 60 ++
.../hooks/use-current-user-authorization.ts | 53 ++
.../src/hooks/use-provider-accounts.test.tsx | 41 +-
.../web/src/hooks/use-provider-accounts.ts | 14 +-
packages/web/src/hooks/use-repos.test.tsx | 31 +
packages/web/src/hooks/use-repos.ts | 11 +-
.../use-workspace-administration.test.tsx | 55 ++
.../src/hooks/use-workspace-administration.ts | 67 +++
61 files changed, 2693 insertions(+), 506 deletions(-)
create mode 100644 packages/control-plane/test/integration/rbac-routes.test.ts
create mode 100644 packages/web/src/app/api/me/authorization/route.ts
create mode 100644 packages/web/src/app/api/members/[userId]/role/route.ts
create mode 100644 packages/web/src/app/api/members/[userId]/status/route.ts
create mode 100644 packages/web/src/app/api/members/route.ts
create mode 100644 packages/web/src/app/api/roles/[id]/route.ts
create mode 100644 packages/web/src/app/api/roles/route.ts
create mode 100644 packages/web/src/components/settings/secrets-settings.test.tsx
create mode 100644 packages/web/src/components/settings/settings-registry.test.ts
create mode 100644 packages/web/src/components/settings/skills-settings/index.test.tsx
create mode 100644 packages/web/src/components/settings/workspace-settings.test.tsx
create mode 100644 packages/web/src/components/settings/workspace-settings.tsx
create mode 100644 packages/web/src/hooks/use-current-user-authorization.test.tsx
create mode 100644 packages/web/src/hooks/use-current-user-authorization.ts
create mode 100644 packages/web/src/hooks/use-repos.test.tsx
create mode 100644 packages/web/src/hooks/use-workspace-administration.test.tsx
create mode 100644 packages/web/src/hooks/use-workspace-administration.ts
diff --git a/packages/control-plane/src/routes/rbac.ts b/packages/control-plane/src/routes/rbac.ts
index 01ed45447..919cb0304 100644
--- a/packages/control-plane/src/routes/rbac.ts
+++ b/packages/control-plane/src/routes/rbac.ts
@@ -1,4 +1,14 @@
-import { AuthorizationError, AuthorizationService } from "../authorization/service";
+import { isCanonicalUserId } from "@open-inspect/shared/user-id";
+import {
+ replaceMemberRoleInputSchema,
+ replaceMemberStatusInputSchema,
+} from "@open-inspect/shared/rbac";
+import { ZodError } from "zod";
+import {
+ AuthorizationError,
+ AuthorizationService,
+ RbacConflictError,
+} from "../authorization/service";
import type { Env } from "../types";
import type { Route } from "./shared";
import {
@@ -7,6 +17,7 @@ import {
defineRoutes,
error,
json,
+ parseJsonBody,
requirePermission,
type UserRouteContext,
} from "./shared";
@@ -22,6 +33,10 @@ function rbacErrorResponse(cause: unknown): Response {
cause.status
);
}
+ if (cause instanceof RbacConflictError) {
+ return json({ error: cause.message, code: "rbac_conflict" }, 409);
+ }
+ if (cause instanceof ZodError) return error("Invalid request body", 400);
return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503);
}
@@ -82,6 +97,56 @@ async function handleListMembers(
}
}
+async function handleReplaceMemberRole(
+ request: Request,
+ _env: Env,
+ match: RegExpMatchArray,
+ ctx: UserRouteContext
+): Promise {
+ const targetUserId = decodeURIComponent(match.groups!.id);
+ if (!isCanonicalUserId(targetUserId)) return error("Invalid user ID", 400);
+ const body = await parseJsonBody(request);
+ if (body instanceof Response) return body;
+ const service = new AuthorizationService(ctx.db);
+ try {
+ const parsed = replaceMemberRoleInputSchema.parse(body);
+ await service.replaceMemberRole({
+ targetUserId,
+ roleId: parsed.roleId,
+ actorUserId: ctx.principal.userId,
+ requestId: ctx.request_id,
+ });
+ return json(await service.getEffectiveAuthorization(targetUserId));
+ } catch (cause) {
+ return rbacErrorResponse(cause);
+ }
+}
+
+async function handleReplaceMemberStatus(
+ request: Request,
+ _env: Env,
+ match: RegExpMatchArray,
+ ctx: UserRouteContext
+): Promise {
+ const targetUserId = decodeURIComponent(match.groups!.id);
+ if (!isCanonicalUserId(targetUserId)) return error("Invalid user ID", 400);
+ const body = await parseJsonBody(request);
+ if (body instanceof Response) return body;
+ const service = new AuthorizationService(ctx.db);
+ try {
+ const parsed = replaceMemberStatusInputSchema.parse(body);
+ await service.replaceMemberStatus({
+ targetUserId,
+ suspended: parsed.suspended,
+ actorUserId: ctx.principal.userId,
+ requestId: ctx.request_id,
+ });
+ return json(await service.getEffectiveAuthorization(targetUserId));
+ } catch (cause) {
+ return rbacErrorResponse(cause);
+ }
+}
+
export const rbacRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [
{
method: "GET",
@@ -111,4 +176,18 @@ export const rbacRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [
cacheControl: "private, no-store",
handler: handleListMembers,
},
+ {
+ method: "PUT",
+ pattern: /^\/members\/(?[^/]+)\/role$/,
+ authorization: requirePermission("workspace.members.manage"),
+ cacheControl: "private, no-store",
+ handler: handleReplaceMemberRole,
+ },
+ {
+ method: "PUT",
+ pattern: /^\/members\/(?[^/]+)\/status$/,
+ authorization: requirePermission("workspace.members.manage"),
+ cacheControl: "private, no-store",
+ handler: handleReplaceMemberStatus,
+ },
]);
diff --git a/packages/control-plane/test/integration/rbac-routes.test.ts b/packages/control-plane/test/integration/rbac-routes.test.ts
new file mode 100644
index 000000000..da0bf6514
--- /dev/null
+++ b/packages/control-plane/test/integration/rbac-routes.test.ts
@@ -0,0 +1,560 @@
+import { env, SELF } from "cloudflare:test";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { AuthorizationService } from "../../src/authorization/service";
+import { UserStore } from "../../src/db/user-store";
+import { mergeUsers } from "../../src/db/user-merge";
+import { cleanD1Tables } from "./cleanup";
+import { serviceFetch, sqlDatabase } from "./helpers";
+
+describe("RBAC routes", () => {
+ beforeEach(cleanD1Tables);
+ afterEach(cleanD1Tables);
+
+ async function seedOwner(): Promise {
+ expect((await serviceFetch("https://cp.test/me/authorization")).status).toBe(200);
+ const user = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{
+ id: string;
+ }>();
+ if (!user) throw new Error("Browser user was not seeded");
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = ?"
+ )
+ .bind(user.id)
+ .run();
+ return user.id;
+ }
+
+ it("keeps ordinary browser users as Member without an Owner assignment", async () => {
+ const first = await serviceFetch("https://cp.test/me/authorization", {
+ initialUserRole: "member",
+ });
+ expect(first.status).toBe(200);
+ await expect(first.json()).resolves.toMatchObject({
+ suspendedAt: null,
+ role: { key: "member" },
+ });
+ });
+
+ it("assigns Member to identities created after the migration boundary", async () => {
+ const user = await new UserStore(sqlDatabase(env.DB)).createUser({
+ displayName: "New Member",
+ email: "member@example.com",
+ emailVerified: true,
+ });
+
+ const assignment = await env.DB.prepare(
+ `SELECT r.key FROM user_role_assignments ura
+ JOIN roles r ON r.id = ura.role_id WHERE ura.user_id = ?`
+ )
+ .bind(user.id)
+ .first();
+ expect(assignment).toEqual({ key: "member" });
+ });
+
+ it("assigns Member at the database boundary for Better Auth and old-worker inserts", async () => {
+ const userId = "22222222222222222222222222222222";
+ await env.DB.prepare(
+ `INSERT INTO users
+ (id, display_name, email, email_verified, avatar_url, created_at, updated_at)
+ VALUES (?, 'Direct User', 'direct@example.com', 1, NULL, 1, 1)`
+ )
+ .bind(userId)
+ .run();
+
+ expect(
+ await env.DB.prepare(
+ `SELECT r.key FROM user_role_assignments ura
+ JOIN roles r ON r.id = ura.role_id WHERE ura.user_id = ?`
+ )
+ .bind(userId)
+ .first()
+ ).toEqual({ key: "member" });
+ });
+
+ it("suspends an emailed member without an Owner assignment", async () => {
+ await serviceFetch("https://cp.test/me/authorization");
+ const actor = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ const member = await new UserStore(sqlDatabase(env.DB)).createUser({
+ displayName: "Suspendable Member",
+ email: "member@example.com",
+ emailVerified: true,
+ });
+ const service = new AuthorizationService(sqlDatabase(env.DB));
+
+ await service.replaceMemberStatus({
+ targetUserId: member.id,
+ suspended: true,
+ actorUserId: actor!.id,
+ requestId: "suspend-without-bootstrap",
+ });
+
+ await expect(service.getEffectiveAuthorization(member.id)).resolves.toMatchObject({
+ suspendedAt: expect.any(Number),
+ });
+ });
+
+ it("fails closed when an existing user has no role assignment", async () => {
+ await serviceFetch("https://cp.test/me/authorization");
+ const user = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ await env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?")
+ .bind(user!.id)
+ .run();
+
+ const response = await serviceFetch("https://cp.test/me/authorization");
+ const personalRoute = await serviceFetch("https://cp.test/keyboard-shortcuts");
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({ code: "assignment_required" });
+ expect(personalRoute.status).toBe(403);
+ await expect(personalRoute.json()).resolves.toMatchObject({ code: "assignment_required" });
+ expect(
+ await env.DB.prepare("SELECT * FROM user_role_assignments WHERE user_id = ?")
+ .bind(user!.id)
+ .first()
+ ).toBeNull();
+ });
+
+ it("uses code-owned permissions for built-in role authorization", async () => {
+ await serviceFetch("https://cp.test/me/authorization", { initialUserRole: "member" });
+ const member = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ const permission = "workspace.roles.read";
+ await env.DB.prepare(
+ "INSERT INTO role_permissions (role_id, permission_id) VALUES ('role_builtin_member', ?)"
+ )
+ .bind(permission)
+ .run();
+
+ try {
+ const authorization = await new AuthorizationService(
+ sqlDatabase(env.DB)
+ ).getEffectiveAuthorization(member!.id);
+ expect(authorization.permissions).not.toContain(permission);
+ expect((await serviceFetch("https://cp.test/roles")).status).toBe(403);
+ } finally {
+ await env.DB.prepare(
+ "DELETE FROM role_permissions WHERE role_id = 'role_builtin_member' AND permission_id = ?"
+ )
+ .bind(permission)
+ .run();
+ }
+ });
+
+ it("never resolves ownership transfer from a custom role", async () => {
+ await serviceFetch("https://cp.test/me/authorization");
+ const user = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ const roleId = "role_custom_owner";
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO roles
+ (id, key, name, normalized_name, description, is_system)
+ VALUES (?, NULL, 'Custom Owner', 'custom owner', NULL, 0)`
+ ).bind(roleId),
+ env.DB.prepare(
+ `INSERT INTO role_permissions (role_id, permission_id)
+ VALUES (?, 'workspace.roles.read'), (?, 'workspace.transfer_ownership')`
+ ).bind(roleId, roleId),
+ env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?").bind(
+ roleId,
+ user!.id
+ ),
+ ]);
+
+ const authorization = await new AuthorizationService(
+ sqlDatabase(env.DB)
+ ).getEffectiveAuthorization(user!.id);
+ expect(authorization.permissions).toContain("workspace.roles.read");
+ expect(authorization.permissions).not.toContain("workspace.transfer_ownership");
+ });
+
+ it("requires sessions.create in addition to parent collaboration when spawning a child", async () => {
+ await serviceFetch("https://cp.test/me/authorization");
+ const user = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ const roleId = "role_child_collaborator";
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO roles
+ (id, key, name, normalized_name, description, is_system)
+ VALUES (?, NULL, 'Child Collaborator', 'child collaborator', NULL, 0)`
+ ).bind(roleId),
+ env.DB.prepare(
+ "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, 'sessions.collaborate')"
+ ).bind(roleId),
+ env.DB.prepare(`UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?`).bind(
+ roleId,
+ user!.id
+ ),
+ ]);
+
+ const response = await serviceFetch("https://cp.test/sessions/parent/children", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ prompt: "Investigate" }),
+ });
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({
+ code: "permission_required",
+ permission: "sessions.create",
+ });
+ });
+
+ it("denies sensitive business mutations to Viewer", async () => {
+ await serviceFetch("https://cp.test/me/authorization");
+ const user = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_viewer' WHERE user_id = ?"
+ )
+ .bind(user!.id)
+ .run();
+
+ const response = await serviceFetch("https://cp.test/secrets", {
+ method: "PUT",
+ body: JSON.stringify({ secrets: { SHOULD_NOT_WRITE: "secret" } }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({
+ code: "permission_required",
+ permission: "global_secrets.manage",
+ });
+
+ await env.DB.prepare(
+ `INSERT INTO sessions (id, repo_owner, repo_name, status, created_at, updated_at)
+ VALUES ('viewer-session', 'acme', 'app', 'completed', 1, 1)`
+ ).run();
+ const sessionDelete = await serviceFetch("https://cp.test/sessions/viewer-session", {
+ method: "DELETE",
+ });
+ expect(sessionDelete.status).toBe(403);
+ await expect(sessionDelete.json()).resolves.toMatchObject({
+ code: "permission_required",
+ permission: "sessions.delete",
+ });
+
+ const read = await serviceFetch("https://cp.test/sessions/viewer-session");
+ expect(read.status).not.toBe(403);
+
+ for (const [path, method, permission, body] of [
+ ["/sessions", "POST", "sessions.create", { title: "Denied", model: "test/model" }],
+ ["/sessions/viewer-session/prompt", "POST", "sessions.collaborate", { content: "Denied" }],
+ ["/sessions/viewer-session/stop", "POST", "sessions.lifecycle", undefined],
+ ["/sessions/viewer-session/sandbox-access", "GET", "sessions.sandbox_access", undefined],
+ ["/skill-profiles", "GET", "skill_profiles.manage_own", undefined],
+ ["/skill-profiles", "POST", "skill_profiles.manage_own", { name: "Denied", skillIds: [] }],
+ ["/skill-profiles/profile-1", "PATCH", "skill_profiles.manage_own", { name: "Denied" }],
+ ["/skill-profiles/profile-1", "DELETE", "skill_profiles.manage_own", undefined],
+ ["/model-provider-accounts", "GET", "provider_accounts.read", undefined],
+ ["/model-provider-account-defaults", "GET", "provider_accounts.read", undefined],
+ ["/model-provider-accounts/legacy-credentials", "GET", "provider_accounts.read", undefined],
+ ] as const) {
+ const denied = await serviceFetch(`https://cp.test${path}`, {
+ method,
+ ...(body
+ ? { body: JSON.stringify(body), headers: { "Content-Type": "application/json" } }
+ : {}),
+ });
+ expect(denied.status, path).toBe(403);
+ await expect(denied.json()).resolves.toMatchObject({
+ code: "permission_required",
+ permission,
+ });
+ }
+ });
+
+ it("allows Members to discover and delete sessions workspace-wide", async () => {
+ await serviceFetch("https://cp.test/me/authorization", { initialUserRole: "member" });
+ const member = await env.DB.prepare(
+ "SELECT id FROM users WHERE email = 'browser@test.local'"
+ ).first<{ id: string }>();
+ const other = await new UserStore(sqlDatabase(env.DB)).createUser({ displayName: "Other" });
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO sessions (id, repo_owner, repo_name, status, created_at, updated_at, user_id)
+ VALUES ('member-session', 'acme', 'app', 'completed', 1, 1, ?)`
+ ).bind(member!.id),
+ env.DB.prepare(
+ `INSERT INTO sessions (id, repo_owner, repo_name, status, created_at, updated_at, user_id)
+ VALUES ('other-session', 'acme', 'app', 'completed', 2, 2, ?)`
+ ).bind(other.id),
+ env.DB.prepare(
+ `INSERT INTO sessions (id, repo_owner, repo_name, status, created_at, updated_at, user_id)
+ VALUES ('unjoined-session', 'acme', 'app', 'completed', 3, 3, ?)`
+ ).bind(other.id),
+ ]);
+
+ const listed = await serviceFetch("https://cp.test/sessions");
+ const lifecycle = await serviceFetch("https://cp.test/sessions/other-session/stop", {
+ method: "POST",
+ });
+ const sandboxAccess = await serviceFetch(
+ "https://cp.test/sessions/other-session/sandbox-access"
+ );
+ const otherDelete = await serviceFetch("https://cp.test/sessions/other-session", {
+ method: "DELETE",
+ });
+ const ownDelete = await serviceFetch("https://cp.test/sessions/member-session", {
+ method: "DELETE",
+ });
+
+ expect(listed.status).toBe(200);
+ expect(lifecycle.status).not.toBe(403);
+ expect(sandboxAccess.status).not.toBe(403);
+ await expect(listed.json()).resolves.toMatchObject({
+ sessions: [{ id: "unjoined-session" }, { id: "other-session" }, { id: "member-session" }],
+ });
+ expect(otherDelete.status).toBe(200);
+ expect(ownDelete.status).toBe(200);
+ expect(
+ await env.DB.prepare("SELECT id FROM sessions WHERE id = 'other-session'").first()
+ ).toBeNull();
+ });
+
+ it("does not let the last unsuspended Owner be suspended", async () => {
+ await seedOwner();
+ const owner = await env.DB.prepare(
+ `SELECT u.id
+ FROM users u
+ JOIN user_role_assignments ura ON ura.user_id = u.id
+ JOIN roles r ON r.id = ura.role_id
+ WHERE r.key = 'owner'`
+ ).first<{ id: string }>();
+ expect(owner).not.toBeNull();
+
+ const response = await serviceFetch(`https://cp.test/members/${owner!.id}/status`, {
+ method: "PUT",
+ body: JSON.stringify({ suspended: true }),
+ headers: { "Content-Type": "application/json" },
+ });
+ expect(response.status).toBe(409);
+ expect(
+ await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(owner!.id).first()
+ ).toEqual({ suspended_at: null });
+ expect(
+ await env.DB.prepare(
+ "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.member_status_updated'"
+ ).first()
+ ).toEqual({ count: 0 });
+ });
+
+ it("lets an Owner suspend themselves when another unsuspended Owner exists", async () => {
+ const ownerId = await seedOwner();
+ const otherOwner = await new UserStore(sqlDatabase(env.DB)).createUser({
+ displayName: "Other Owner",
+ });
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = ?"
+ )
+ .bind(otherOwner.id)
+ .run();
+
+ const response = await serviceFetch(`https://cp.test/members/${ownerId}/status`, {
+ method: "PUT",
+ body: JSON.stringify({ suspended: true }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(response.status).toBe(200);
+ expect(
+ await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(ownerId).first()
+ ).toEqual({ suspended_at: expect.any(Number) });
+ expect(
+ await env.DB.prepare(
+ "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.member_status_updated'"
+ ).first()
+ ).toEqual({ count: 1 });
+ });
+
+ it("lets an Owner demote themselves when another unsuspended Owner exists", async () => {
+ const ownerId = await seedOwner();
+ const otherOwner = await new UserStore(sqlDatabase(env.DB)).createUser({
+ displayName: "Other Owner",
+ });
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = ?"
+ )
+ .bind(otherOwner.id)
+ .run();
+
+ const response = await serviceFetch(`https://cp.test/members/${ownerId}/role`, {
+ method: "PUT",
+ body: JSON.stringify({ roleId: "role_builtin_administrator" }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toMatchObject({ role: { key: "administrator" } });
+ expect(
+ await env.DB.prepare(
+ "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.member_role_updated'"
+ ).first()
+ ).toEqual({ count: 1 });
+ });
+
+ it("does not let the last unsuspended Owner demote themselves", async () => {
+ const ownerId = await seedOwner();
+
+ const response = await serviceFetch(`https://cp.test/members/${ownerId}/role`, {
+ method: "PUT",
+ body: JSON.stringify({ roleId: "role_builtin_administrator" }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(response.status).toBe(409);
+ expect(
+ await env.DB.prepare(
+ `SELECT r.key FROM user_role_assignments ura
+ JOIN roles r ON r.id = ura.role_id WHERE ura.user_id = ?`
+ )
+ .bind(ownerId)
+ .first()
+ ).toEqual({ key: "owner" });
+ });
+
+ it("derives Owner bootstrap health from an unsuspended Owner assignment", async () => {
+ const pending = await SELF.fetch("https://cp.test/health");
+ await expect(pending.json()).resolves.toMatchObject({
+ rbac: { ownerAssignment: "missing" },
+ });
+
+ const ownerId = await seedOwner();
+ const complete = await SELF.fetch("https://cp.test/health");
+ await expect(complete.json()).resolves.toMatchObject({
+ rbac: { ownerAssignment: "present" },
+ });
+
+ await env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?").bind(ownerId).run();
+ const suspended = await SELF.fetch("https://cp.test/health");
+ await expect(suspended.json()).resolves.toMatchObject({
+ rbac: { ownerAssignment: "missing" },
+ });
+ });
+
+ it("requires an explicit unsuspended Owner assignment before merging an Owner", async () => {
+ const store = new UserStore(sqlDatabase(env.DB));
+ const survivor = await store.createUser({ displayName: "Survivor" });
+ const loser = await store.createUser({ displayName: "Owner" });
+ await env.DB.batch([
+ env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?").bind(survivor.id),
+ env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = ?"
+ ).bind(loser.id),
+ ]);
+
+ await expect(
+ mergeUsers(sqlDatabase(env.DB), {
+ survivorId: survivor.id,
+ loserId: loser.id,
+ dryRun: false,
+ })
+ ).rejects.toThrow("Resolve conflicting user roles before merging");
+ });
+
+ it("rejects privileged mutations when the actor authorization changes", async () => {
+ const ownerId = await seedOwner();
+ const member = await new UserStore(sqlDatabase(env.DB)).createUser({
+ displayName: "Target Member",
+ });
+ const service = new AuthorizationService(sqlDatabase(env.DB));
+ await service.requirePermission(ownerId, "workspace.members.manage");
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_member' WHERE user_id = ?"
+ )
+ .bind(ownerId)
+ .run();
+ await expect(
+ service.replaceMemberRole({
+ targetUserId: member.id,
+ roleId: "role_builtin_administrator",
+ actorUserId: ownerId,
+ requestId: "stale-member-role-request",
+ })
+ ).rejects.toThrow("Actor authorization changed");
+ await expect(
+ service.replaceMemberStatus({
+ targetUserId: member.id,
+ suspended: true,
+ actorUserId: ownerId,
+ requestId: "stale-member-request",
+ })
+ ).rejects.toThrow("Actor authorization changed");
+
+ expect(await service.getEffectiveAuthorization(member.id)).toMatchObject({
+ role: { key: "member" },
+ });
+ expect(
+ await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(member.id).first()
+ ).toEqual({ suspended_at: null });
+ expect(
+ await env.DB.prepare(
+ `SELECT COUNT(*) AS count FROM authorization_audit_events
+ WHERE request_id IN (
+ 'stale-member-role-request', 'stale-member-request'
+ )`
+ ).first()
+ ).toEqual({ count: 0 });
+ });
+
+ it("returns authorization unavailable for an unexpected mutation database failure", async () => {
+ await seedOwner();
+ await env.DB.prepare(
+ `CREATE TRIGGER fail_member_audit
+ BEFORE INSERT ON authorization_audit_events
+ WHEN NEW.action = 'workspace.member_status_updated'
+ BEGIN
+ SELECT RAISE(ABORT, 'forced database failure');
+ END`
+ ).run();
+
+ try {
+ const member = await new UserStore(sqlDatabase(env.DB)).createUser({ displayName: "Member" });
+ const response = await serviceFetch(`https://cp.test/members/${member.id}/status`, {
+ method: "PUT",
+ body: JSON.stringify({ suspended: true }),
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(response.status).toBe(503);
+ await expect(response.json()).resolves.toEqual({
+ error: "Authorization unavailable",
+ code: "authorization_unavailable",
+ });
+ expect(
+ await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(member.id).first()
+ ).toEqual({ suspended_at: null });
+ expect(
+ await env.DB.prepare(
+ "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.member_status_updated'"
+ ).first()
+ ).toEqual({ count: 0 });
+ } finally {
+ await env.DB.prepare("DROP TRIGGER fail_member_audit").run();
+ }
+ });
+
+ it("rejects suspended users at the backend after reauthentication", async () => {
+ await seedOwner();
+ await env.DB.prepare("UPDATE users SET suspended_at = 1").run();
+
+ const response = await serviceFetch("https://cp.test/repos");
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toEqual({
+ error: "Forbidden",
+ code: "active_user_required",
+ });
+ });
+});
diff --git a/packages/web/src/app/(app)/settings/page.test.tsx b/packages/web/src/app/(app)/settings/page.test.tsx
index 131d37a54..cc618d8bc 100644
--- a/packages/web/src/app/(app)/settings/page.test.tsx
+++ b/packages/web/src/app/(app)/settings/page.test.tsx
@@ -7,12 +7,16 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import SettingsPage from "./page";
import { SettingsViewportProvider } from "@/components/settings/settings-viewport-context";
+import { PERMISSION_IDS } from "@open-inspect/shared/rbac";
expect.extend(matchers);
const mocks = vi.hoisted(() => ({
tab: null as string | null,
repoImagesEnabled: true,
+ allowedPermissions: new Set(),
+ authorization: { permissions: [] as string[] },
+ hasPermission: (permission: string) => mocks.allowedPermissions.has(permission),
}));
vi.mock("next/navigation", () => ({
@@ -23,6 +27,14 @@ vi.mock("@/lib/sandbox-provider", () => ({
supportsRepoImages: () => mocks.repoImagesEnabled,
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ authorization: mocks.authorization,
+ loading: false,
+ hasPermission: mocks.hasPermission,
+ }),
+}));
+
vi.mock("@/components/settings/secrets-settings", () => ({
SecretsSettings: () => Secrets panel
,
}));
@@ -66,6 +78,8 @@ vi.mock("@/components/settings/mcp-servers-settings", () => ({
beforeEach(() => {
mocks.tab = null;
mocks.repoImagesEnabled = true;
+ mocks.allowedPermissions = new Set(PERMISSION_IDS);
+ mocks.authorization.permissions = [...PERMISSION_IDS];
window.history.replaceState(null, "", "/settings");
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
@@ -94,8 +108,8 @@ describe("SettingsPage mobile navigation", () => {
await user.click(screen.getByRole("button", { name: /Appearance/ }));
- expect(screen.getByRole("heading", { name: "Appearance" })).toHaveFocus();
- expect(screen.getByText("Appearance panel")).toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: "Appearance" })).toHaveFocus();
+ expect(await screen.findByText("Appearance panel")).toBeInTheDocument();
expect(window.location.href).toContain("/settings?tab=appearance");
expect(window.history.state).toMatchObject({ openInspectSettingsDetail: true });
@@ -139,6 +153,42 @@ describe("SettingsPage mobile navigation", () => {
expect(window.location.search).toBe("");
});
+ it("redirects an unauthorized deep link to the first available panel", () => {
+ mocks.tab = "secrets";
+ mocks.allowedPermissions = new Set();
+ mocks.authorization.permissions = [];
+ window.history.replaceState(null, "", "/settings?tab=secrets");
+
+ renderSettingsPage();
+
+ expect(screen.getByRole("heading", { name: "Appearance" })).toBeInTheDocument();
+ expect(screen.getByText("Appearance panel")).toBeInTheDocument();
+ expect(screen.queryByText("Secrets panel")).not.toBeInTheDocument();
+ });
+
+ it("allows repository secret managers to open secrets", async () => {
+ mocks.tab = "secrets";
+ mocks.allowedPermissions = new Set(["repositories.secrets.manage", "repositories.read"]);
+ mocks.authorization.permissions = ["repositories.secrets.manage", "repositories.read"];
+ window.history.replaceState(null, "", "/settings?tab=secrets");
+
+ renderSettingsPage();
+
+ expect(await screen.findByText("Secrets panel")).toBeInTheDocument();
+ });
+
+ it("rejects repository secret managers without repository read access", async () => {
+ mocks.tab = "secrets";
+ mocks.allowedPermissions = new Set(["repositories.secrets.manage"]);
+ mocks.authorization.permissions = ["repositories.secrets.manage"];
+ window.history.replaceState(null, "", "/settings?tab=secrets");
+
+ renderSettingsPage();
+
+ expect(await screen.findByText("Appearance panel")).toBeInTheDocument();
+ expect(screen.queryByText("Secrets panel")).not.toBeInTheDocument();
+ });
+
it("uses browser history for the in-app back action", async () => {
const back = vi.spyOn(window.history, "back").mockImplementation(() => undefined);
const user = userEvent.setup();
diff --git a/packages/web/src/app/(app)/settings/page.tsx b/packages/web/src/app/(app)/settings/page.tsx
index ee1f3d8f8..c42f5c9f2 100644
--- a/packages/web/src/app/(app)/settings/page.tsx
+++ b/packages/web/src/app/(app)/settings/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Suspense, useEffect, useRef, useState, type ComponentType } from "react";
+import { Suspense, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import {
DEFAULT_SETTINGS_CATEGORY,
@@ -11,45 +11,17 @@ import {
} from "@/components/settings/settings-nav";
import { SettingsMobileHeader } from "@/components/settings/settings-mobile-header";
import { useSettingsIsMobile } from "@/components/settings/settings-viewport-context";
-import { SecretsSettings } from "@/components/settings/secrets-settings";
-import { EnvironmentsSettings } from "@/components/settings/environments-settings";
-import { ModelsSettings } from "@/components/settings/models-settings";
-import { DataControlsSettings } from "@/components/settings/data-controls-settings";
-import { KeyboardShortcutsSettings } from "@/components/settings/keyboard-shortcuts-settings";
-import { IntegrationsSettings } from "@/components/settings/integrations-settings";
-import { SandboxSettingsPage } from "@/components/settings/sandbox-settings";
-import { ScmSettingsPage } from "@/components/settings/scm-settings";
-import { ImagesSettings } from "@/components/settings/images-settings";
-import { McpServersSettings } from "@/components/settings/mcp-servers-settings";
-import { AppearanceSettings } from "@/components/settings/appearance-settings";
-import { ProviderAccountsSettings } from "@/components/settings/provider-accounts-settings";
-import { SkillsSettings } from "@/components/settings/skills-settings";
import { supportsRepoImages } from "@/lib/sandbox-provider";
-
-const SETTINGS_PANELS: Record = {
- appearance: AppearanceSettings,
- "keyboard-shortcuts": KeyboardShortcutsSettings,
- models: ModelsSettings,
- "provider-accounts": ProviderAccountsSettings,
- skills: SkillsSettings,
- environments: EnvironmentsSettings,
- secrets: SecretsSettings,
- scm: ScmSettingsPage,
- sandbox: SandboxSettingsPage,
- images: ImagesSettings,
- integrations: IntegrationsSettings,
- "mcp-servers": McpServersSettings,
- "data-controls": DataControlsSettings,
-};
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
+import { getSettingsPanel, resolveSettingsCategory } from "@/components/settings/settings-registry";
function SettingsPageContent() {
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
const repoImagesEnabled = supportsRepoImages();
const isMobile = useSettingsIsMobile();
- const initialCategory = isSettingsCategory(tabParam, repoImagesEnabled)
- ? tabParam
- : DEFAULT_SETTINGS_CATEGORY;
+ const { hasPermission, loading } = useCurrentUserAuthorization();
+ const initialCategory = resolveSettingsCategory(tabParam, repoImagesEnabled, hasPermission);
const [activeCategory, setActiveCategoryRaw] = useState(initialCategory);
function selectCategory(category: SettingsCategory, trigger: HTMLButtonElement) {
@@ -101,7 +73,7 @@ function SettingsPageContent() {
const syncFromHistory = () => {
const requestedCategory = new URLSearchParams(window.location.search).get("tab");
const nextCategory = isSettingsCategory(requestedCategory, repoImagesEnabled)
- ? requestedCategory
+ ? resolveSettingsCategory(requestedCategory, repoImagesEnabled, hasPermission)
: null;
if (nextCategory) {
setActiveCategoryRaw(nextCategory);
@@ -121,27 +93,34 @@ function SettingsPageContent() {
window.addEventListener("popstate", syncFromHistory);
return () => window.removeEventListener("popstate", syncFromHistory);
- }, [isMobile, repoImagesEnabled]);
+ }, [hasPermission, isMobile, repoImagesEnabled]);
// Sync state when searchParams change via client-side navigation
useEffect(() => {
if (isSettingsCategory(tabParam, repoImagesEnabled)) {
- setActiveCategoryRaw(tabParam);
+ setActiveCategoryRaw(resolveSettingsCategory(tabParam, repoImagesEnabled, hasPermission));
setMobileView("detail");
return;
}
if (!isMobile || !mobileTriggerRef.current) {
- setActiveCategoryRaw(DEFAULT_SETTINGS_CATEGORY);
+ setActiveCategoryRaw(resolveSettingsCategory(null, repoImagesEnabled, hasPermission));
}
setMobileView("list");
- }, [isMobile, repoImagesEnabled, tabParam]);
+ }, [hasPermission, isMobile, repoImagesEnabled, tabParam]);
- const renderedCategory = isSettingsCategory(activeCategory, repoImagesEnabled)
- ? activeCategory
- : DEFAULT_SETTINGS_CATEGORY;
- const ActivePanel = SETTINGS_PANELS[renderedCategory];
- const content = ;
+ if (loading) return null;
+ const renderedCategory = resolveSettingsCategory(
+ activeCategory,
+ repoImagesEnabled,
+ hasPermission
+ );
+ const ActivePanel = getSettingsPanel(renderedCategory);
+ const content = (
+
+
+
+ );
if (isMobile) {
return (
diff --git a/packages/web/src/app/api/me/authorization/route.ts b/packages/web/src/app/api/me/authorization/route.ts
new file mode 100644
index 000000000..33ea39ecc
--- /dev/null
+++ b/packages/web/src/app/api/me/authorization/route.ts
@@ -0,0 +1,6 @@
+import { controlPlaneJsonGetProxy } from "@/lib/control-plane-json-proxy";
+
+export const { GET } = controlPlaneJsonGetProxy(
+ () => "/me/authorization",
+ "current user authorization"
+);
diff --git a/packages/web/src/app/api/members/[userId]/role/route.ts b/packages/web/src/app/api/members/[userId]/role/route.ts
new file mode 100644
index 000000000..e1df2734b
--- /dev/null
+++ b/packages/web/src/app/api/members/[userId]/role/route.ts
@@ -0,0 +1,6 @@
+import { settingsProxy } from "@/lib/settings-proxy";
+
+export const { PUT } = settingsProxy(
+ ({ userId }: { userId: string }) => `/members/${encodeURIComponent(userId)}/role`,
+ "member role"
+);
diff --git a/packages/web/src/app/api/members/[userId]/status/route.ts b/packages/web/src/app/api/members/[userId]/status/route.ts
new file mode 100644
index 000000000..5085d3013
--- /dev/null
+++ b/packages/web/src/app/api/members/[userId]/status/route.ts
@@ -0,0 +1,6 @@
+import { settingsProxy } from "@/lib/settings-proxy";
+
+export const { PUT } = settingsProxy(
+ ({ userId }: { userId: string }) => `/members/${encodeURIComponent(userId)}/status`,
+ "member status"
+);
diff --git a/packages/web/src/app/api/members/route.ts b/packages/web/src/app/api/members/route.ts
new file mode 100644
index 000000000..ebf90baa5
--- /dev/null
+++ b/packages/web/src/app/api/members/route.ts
@@ -0,0 +1,3 @@
+import { settingsProxy } from "@/lib/settings-proxy";
+
+export const { GET } = settingsProxy(() => "/members", "members");
diff --git a/packages/web/src/app/api/roles/[id]/route.ts b/packages/web/src/app/api/roles/[id]/route.ts
new file mode 100644
index 000000000..c1aadbf3c
--- /dev/null
+++ b/packages/web/src/app/api/roles/[id]/route.ts
@@ -0,0 +1,6 @@
+import { settingsProxy } from "@/lib/settings-proxy";
+
+export const { GET } = settingsProxy(
+ ({ id }: { id: string }) => `/roles/${encodeURIComponent(id)}`,
+ "role"
+);
diff --git a/packages/web/src/app/api/roles/route.ts b/packages/web/src/app/api/roles/route.ts
new file mode 100644
index 000000000..36fe74458
--- /dev/null
+++ b/packages/web/src/app/api/roles/route.ts
@@ -0,0 +1,3 @@
+import { settingsProxy } from "@/lib/settings-proxy";
+
+export const { GET } = settingsProxy(() => "/roles", "roles");
diff --git a/packages/web/src/components/app-auth-boundary.test.tsx b/packages/web/src/components/app-auth-boundary.test.tsx
index 80a2bfaa6..f627da84a 100644
--- a/packages/web/src/components/app-auth-boundary.test.tsx
+++ b/packages/web/src/components/app-auth-boundary.test.tsx
@@ -6,16 +6,33 @@ import * as matchers from "@testing-library/jest-dom/matchers";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAuthSession } from "@/lib/auth-session";
import { AppAuthBoundary } from "./app-auth-boundary";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
expect.extend(matchers);
vi.mock("@/lib/auth-session", () => ({
useAuthSession: vi.fn(),
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: vi.fn(),
+}));
+
+const activeAuthorization = {
+ userId: "11111111111111111111111111111111",
+ suspendedAt: null,
+ role: { id: "role_builtin_member", key: "member" as const, name: "Member" },
+ permissions: ["repositories.read" as const],
+};
afterEach(() => {
cleanup();
vi.clearAllMocks();
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: null,
+ loading: false,
+ error: null,
+ hasPermission: () => false,
+ });
});
describe("AppAuthBoundary", () => {
@@ -24,6 +41,12 @@ describe("AppAuthBoundary", () => {
data: { user: { id: "user-1", name: "Test User" } },
status: "authenticated",
});
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: activeAuthorization,
+ loading: false,
+ error: null,
+ hasPermission: () => true,
+ });
render(Session );
@@ -59,6 +82,23 @@ describe("AppAuthBoundary", () => {
expect(screen.queryByRole("link", { name: "Sign in" })).not.toBeInTheDocument();
});
+ it("fails closed when workspace access is suspended", () => {
+ vi.mocked(useAuthSession).mockReturnValue({
+ data: { user: { id: "user-1", name: "Test User" } },
+ status: "authenticated",
+ });
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: { ...activeAuthorization, suspendedAt: 1, permissions: [] },
+ loading: false,
+ error: null,
+ hasPermission: () => false,
+ });
+
+ render(Session );
+
+ expect(screen.getByRole("alert")).toHaveTextContent("Your workspace access is disabled.");
+ });
+
it("fails closed for an unhandled authentication state", () => {
vi.mocked(useAuthSession).mockReturnValue({
data: null,
diff --git a/packages/web/src/components/app-auth-boundary.tsx b/packages/web/src/components/app-auth-boundary.tsx
index 68c5e978b..0ae752692 100644
--- a/packages/web/src/components/app-auth-boundary.tsx
+++ b/packages/web/src/components/app-auth-boundary.tsx
@@ -5,9 +5,14 @@ import { useAuthSession } from "@/lib/auth-session";
import { APP_NAME } from "@/lib/site-config";
import { Button } from "@/components/ui/button";
import { ErrorBanner } from "@/components/ui/error-banner";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
+/**
+ * Renders application children only for authenticated, active workspace users after authorization resolves.
+ */
export function AppAuthBoundary({ children }: { children: React.ReactNode }) {
const { status } = useAuthSession();
+ const { authorization, loading: authorizationLoading, error } = useCurrentUserAuthorization();
if (status === "loading") {
return (
@@ -47,6 +52,31 @@ export function AppAuthBoundary({ children }: { children: React.ReactNode }) {
}
if (status === "authenticated") {
+ if (authorizationLoading) {
+ return (
+
+ );
+ }
+ if (error || !authorization) {
+ return (
+
+ Authorization is temporarily unavailable.
+
+ );
+ }
+ if (authorization.suspendedAt !== null) {
+ return (
+
+ Your workspace access is disabled.
+
+ );
+ }
return children;
}
diff --git a/packages/web/src/components/automations/automations-list.test.tsx b/packages/web/src/components/automations/automations-list.test.tsx
index b28960020..5c20c2bbb 100644
--- a/packages/web/src/components/automations/automations-list.test.tsx
+++ b/packages/web/src/components/automations/automations-list.test.tsx
@@ -41,6 +41,7 @@ function makeAutomation(overrides: Partial = {}): Automation
nextRunAt: null,
consecutiveFailures: 0,
createdBy: "user-1",
+ userId: "11111111111111111111111111111111",
createdAt: Date.now(),
updatedAt: Date.now(),
deletedAt: null,
diff --git a/packages/web/src/components/global-command-menu.test.tsx b/packages/web/src/components/global-command-menu.test.tsx
index c6e7baa6d..448943f91 100644
--- a/packages/web/src/components/global-command-menu.test.tsx
+++ b/packages/web/src/components/global-command-menu.test.tsx
@@ -15,7 +15,10 @@ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
value: vi.fn(),
});
-const mocks = vi.hoisted(() => ({ repoImagesEnabled: true }));
+const mocks = vi.hoisted(() => ({
+ repoImagesEnabled: true,
+ allowedPermissions: null as Set | null,
+}));
vi.mock("@/hooks/use-keyboard-shortcuts", () => ({
useKeyboardShortcuts: () => ({ labels: { "new-session": "Cmd/Ctrl+Shift+O" } }),
@@ -25,6 +28,13 @@ vi.mock("@/lib/sandbox-provider", () => ({
supportsRepoImages: () => mocks.repoImagesEnabled,
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ mocks.allowedPermissions === null || mocks.allowedPermissions.has(permission),
+ }),
+}));
+
beforeEach(() => {
vi.stubGlobal(
"ResizeObserver",
@@ -39,6 +49,7 @@ beforeEach(() => {
afterEach(() => {
cleanup();
mocks.repoImagesEnabled = true;
+ mocks.allowedPermissions = null;
vi.unstubAllGlobals();
});
@@ -70,6 +81,16 @@ describe("GlobalCommandMenu", () => {
).toBeInTheDocument();
});
+ it("omits session creation destinations without session creation permission", () => {
+ mocks.allowedPermissions = new Set();
+
+ renderMenu();
+
+ expect(screen.queryByText("New session")).not.toBeInTheDocument();
+ expect(screen.queryByText("Home")).not.toBeInTheDocument();
+ expect(screen.queryByText("Start a coding session")).not.toBeInTheDocument();
+ });
+
it("selects Analytics from the keyboard", async () => {
const user = userEvent.setup();
const { onNavigate, onOpenChange } = renderMenu();
@@ -161,6 +182,27 @@ describe("GlobalCommandMenu", () => {
expect(screen.queryByText("Images")).not.toBeInTheDocument();
});
+ it("omits settings destinations the user cannot view", () => {
+ mocks.allowedPermissions = new Set(["models.preferences.manage"]);
+ renderMenu();
+
+ expect(screen.getByText("Appearance")).toBeInTheDocument();
+ expect(screen.getByText("Models")).toBeInTheDocument();
+ expect(screen.queryByText("Secrets")).not.toBeInTheDocument();
+ });
+
+ it("requires repository read access for the repository secrets destination", () => {
+ mocks.allowedPermissions = new Set(["repositories.secrets.manage"]);
+ const { rerender, props } = renderMenu();
+
+ expect(screen.queryByText("Secrets")).not.toBeInTheDocument();
+
+ mocks.allowedPermissions.add("repositories.read");
+ rerender( );
+
+ expect(screen.getByText("Secrets")).toBeInTheDocument();
+ });
+
it("preserves order-independent session search", async () => {
const user = userEvent.setup();
renderMenu([
diff --git a/packages/web/src/components/global-command-menu.tsx b/packages/web/src/components/global-command-menu.tsx
index e341a7588..5eb93d47a 100644
--- a/packages/web/src/components/global-command-menu.tsx
+++ b/packages/web/src/components/global-command-menu.tsx
@@ -11,6 +11,7 @@ import { BranchIcon, PlusIcon } from "@/components/ui/icons";
import { AppIcon } from "@/components/ui/app-icon";
import { APP_DESTINATIONS } from "@/components/app-destinations";
import { getSettingsGroups } from "@/components/settings/settings-registry";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
import {
Command,
CommandDialog,
@@ -72,6 +73,9 @@ function CommandMenuFooter() {
);
}
+/**
+ * Provides global navigation and search while exposing only settings destinations the user may access.
+ */
export function GlobalCommandMenu({
open,
onOpenChange,
@@ -80,11 +84,13 @@ export function GlobalCommandMenu({
sessions,
}: GlobalCommandMenuProps) {
const { labels } = useKeyboardShortcuts();
+ const { hasPermission } = useCurrentUserAuthorization();
const searchableSessions = useMemo(
() => sessions.filter((session) => session.status !== "archived"),
[sessions]
);
- const settingsGroups = getSettingsGroups();
+ const settingsGroups = getSettingsGroups({ hasPermission });
+ const canCreateSession = hasPermission("sessions.create");
const handleSelect = (callback: () => void) => {
onOpenChange(false);
@@ -92,20 +98,24 @@ export function GlobalCommandMenu({
};
const navigationItems = [
- {
- label: "New session",
- description: "Start a coding session",
- Icon: PlusIcon,
- onSelect: onNewSession,
- shortcut: labels["new-session"],
- },
- {
- label: "Home",
- description: "Ask a question or describe what you want to build",
- Icon: AppIcon,
- onSelect: () => onNavigate("/"),
- shortcut: undefined,
- },
+ ...(canCreateSession
+ ? [
+ {
+ label: "New session",
+ description: "Start a coding session",
+ Icon: PlusIcon,
+ onSelect: onNewSession,
+ shortcut: labels["new-session"],
+ },
+ {
+ label: "Home",
+ description: "Ask a question or describe what you want to build",
+ Icon: AppIcon,
+ onSelect: () => onNavigate("/"),
+ shortcut: undefined,
+ },
+ ]
+ : []),
...APP_DESTINATIONS.map(({ label, description, href, icon: Icon }) => ({
label,
description,
diff --git a/packages/web/src/components/settings/environment-integration-settings.tsx b/packages/web/src/components/settings/environment-integration-settings.tsx
index f63681b53..77061aefd 100644
--- a/packages/web/src/components/settings/environment-integration-settings.tsx
+++ b/packages/web/src/components/settings/environment-integration-settings.tsx
@@ -38,9 +38,11 @@ const ENABLEMENT_CHOICES: Array<{ value: EnablementChoice; label: string }> = [
export function EnvironmentIntegrationSettings({
environmentId,
repositories,
+ canManage,
}: {
environmentId: string;
repositories: EnvironmentRepository[];
+ canManage: boolean;
}) {
const primary = repositories[0];
const primaryLabel = primary
@@ -54,36 +56,38 @@ export function EnvironmentIntegrationSettings({
left unset inherits from {primaryLabel}'s settings.
-
-
-
-
-
Sandbox
-
- Inherited values are shown as the current settings; saving only pins the fields you
- change.
-
-
+
-
+
+
+
+
Sandbox
+
+ Inherited values are shown as the current settings; saving only pins the fields you
+ change.
+
+
+
+
);
}
diff --git a/packages/web/src/components/settings/environments-settings.tsx b/packages/web/src/components/settings/environments-settings.tsx
index 8949d5abe..2ed017607 100644
--- a/packages/web/src/components/settings/environments-settings.tsx
+++ b/packages/web/src/components/settings/environments-settings.tsx
@@ -21,16 +21,28 @@ import { EnvironmentIntegrationSettings } from "./environment-integration-settin
import { EnvironmentSecretsImport } from "./environment-secrets-import";
import { ImageBuildStatus } from "./image-build-status";
import { SecretsEditor } from "@/components/secrets-editor";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
type View =
| { mode: "list" }
| { mode: "create" }
| { mode: "edit"; environmentId: string; tab: "configuration" | "secrets" | "overrides" };
+/**
+ * Presents environments with configuration, secrets, settings, and image actions gated independently by permission.
+ */
export function EnvironmentsSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManage = hasPermission("environments.manage");
+ const canManageSecrets = hasPermission("environments.secrets.manage");
+ const canManageRepoSecrets = hasPermission("repositories.secrets.manage");
+ const canManageSettings = hasPermission("environments.settings.manage");
+ const canManageImages = hasPermission("environments.images.manage");
+ const canReadImages = hasPermission("image_builds.read");
+ const canReadSettings = hasPermission("integrations.read");
const { environments, loading } = useEnvironments();
const { data: imageBuildsFeed, error: imageBuildsError } = useImageBuilds(
- environments.some((environment) => environment.prebuildEnabled)
+ canReadImages && environments.some((environment) => environment.prebuildEnabled)
);
const [view, setView] = useState({ mode: "list" });
const [submitting, setSubmitting] = useState(false);
@@ -208,20 +220,27 @@ export function EnvironmentsSettings() {
- {(["configuration", "secrets", "overrides"] as const).map((tab) => (
- setView({ ...view, tab })}
- className={`px-3 py-2 text-sm capitalize transition border-b-2 -mb-px ${
- view.tab === tab
- ? "border-accent text-foreground font-medium"
- : "border-transparent text-muted-foreground hover:text-foreground"
- }`}
- >
- {tab}
-
- ))}
+ {(["configuration", "secrets", "overrides"] as const)
+ .filter(
+ (tab) =>
+ (tab === "configuration" && canManage) ||
+ (tab === "secrets" && canManageSecrets) ||
+ (tab === "overrides" && canReadSettings)
+ )
+ .map((tab) => (
+ setView({ ...view, tab })}
+ className={`px-3 py-2 text-sm capitalize transition border-b-2 -mb-px ${
+ view.tab === tab
+ ? "border-accent text-foreground font-medium"
+ : "border-transparent text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ {tab}
+
+ ))}
{error && {error} }
@@ -242,10 +261,12 @@ export function EnvironmentsSettings() {
and triggers a rebuild.
-
+ {canManageRepoSecrets && (
+
+ )}
setView({ mode: "list" })}>
Back to environments
@@ -257,6 +278,7 @@ export function EnvironmentsSettings() {
setView({ mode: "list" })}>
@@ -274,9 +296,11 @@ export function EnvironmentsSettings() {
Environments
- setView({ mode: "create" })}>
- New environment
-
+ {canManage && (
+ setView({ mode: "create" })}>
+ New environment
+
+ )}
Named repository sets that launch together in one workspace, with their own secrets
@@ -319,82 +343,100 @@ export function EnvironmentsSettings() {
- {prebuildsSupported && (
+ {prebuildsSupported && (canReadImages || canManage || canManageImages) && (
<>
-
- image.scopeKind === "environment" && image.scopeId === environment.id
- )}
- feedUnavailable={Boolean(imageBuildsError) && !imageBuildsFeed}
- />
-
-
-
-
- handlePrebuildToggle(environment, checked)
- }
- disabled={isToggling}
- aria-label={`Toggle prebuilt images for ${environment.name}`}
- />
-
-
- Prebuild images
-
- handleRebuild(environment)}
- disabled={!environment.prebuildEnabled || isTriggering}
- title="Rebuild image"
- >
-
+ image.scopeKind === "environment" &&
+ image.scopeId === environment.id
+ )}
+ feedUnavailable={Boolean(imageBuildsError) && !imageBuildsFeed}
/>
-
+ )}
+ {canManage && (
+
+
+
+
+ handlePrebuildToggle(environment, checked)
+ }
+ disabled={isToggling}
+ aria-label={`Toggle prebuilt images for ${environment.name}`}
+ />
+
+
+ Prebuild images
+
+ )}
+ {canManageImages && (
+ handleRebuild(environment)}
+ disabled={!environment.prebuildEnabled || isTriggering}
+ title="Rebuild image"
+ >
+
+
+ )}
>
)}
-
- setView({
- mode: "edit",
- environmentId: environment.id,
- tab: "configuration",
- })
- }
- >
- Edit
-
- {confirmDeleteId === environment.id ? (
-
- {
- handleDelete(environment);
- setConfirmDeleteId(null);
- }}
- >
- Confirm
-
- setConfirmDeleteId(null)}>
- Cancel
-
-
- ) : (
+ {(canManage || canManageSecrets || canReadSettings) && (
setConfirmDeleteId(environment.id)}
+ onClick={() =>
+ setView({
+ mode: "edit",
+ environmentId: environment.id,
+ tab: canManage
+ ? "configuration"
+ : canManageSecrets
+ ? "secrets"
+ : "overrides",
+ })
+ }
>
- Delete
+ Edit
)}
+ {canManage &&
+ (confirmDeleteId === environment.id ? (
+
+ {
+ handleDelete(environment);
+ setConfirmDeleteId(null);
+ }}
+ >
+ Confirm
+
+ setConfirmDeleteId(null)}
+ >
+ Cancel
+
+
+ ) : (
+ setConfirmDeleteId(environment.id)}
+ >
+ Delete
+
+ ))}
diff --git a/packages/web/src/components/settings/images-settings.test.tsx b/packages/web/src/components/settings/images-settings.test.tsx
index 9adc1e180..980fe7b2a 100644
--- a/packages/web/src/components/settings/images-settings.test.tsx
+++ b/packages/web/src/components/settings/images-settings.test.tsx
@@ -11,6 +11,15 @@ import { ImagesSettings } from "./images-settings";
expect.extend(matchers);
+const mocks = vi.hoisted(() => ({ allowedPermissions: null as Set | null }));
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ mocks.allowedPermissions === null || mocks.allowedPermissions.has(permission),
+ }),
+}));
+
vi.mock("@/hooks/use-repos", () => ({
useRepos: () => ({
repos: [
@@ -52,6 +61,7 @@ function renderWithFeed(feed: ImageBuildsFeed) {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
+ mocks.allowedPermissions = null;
});
describe("ImagesSettings", () => {
@@ -127,6 +137,20 @@ describe("ImagesSettings", () => {
).not.toBeChecked();
});
+ it("keeps image state visible but disables mutations for a read-only role", () => {
+ mocks.allowedPermissions = new Set(["image_builds.read"]);
+ renderWithFeed({
+ units: [],
+ enabledRepos: [{ repoOwner: "acme", repoName: "web" }],
+ images: [],
+ });
+
+ expect(
+ screen.getByRole("switch", { name: "Toggle pre-built images for acme/web" })
+ ).toBeDisabled();
+ expect(screen.queryByTitle("Rebuild image")).not.toBeInTheDocument();
+ });
+
it("shows an error instead of unchecked toggles when the feed fails", async () => {
render(
handleToggle(repo.owner, repo.name, checked)}
- disabled={isToggling}
+ disabled={!canManage || isToggling}
aria-label={`Toggle pre-built images for ${repo.owner}/${repo.name}`}
/>
@@ -180,15 +186,17 @@ export function ImagesSettings() {
}
}
/>
- handleTrigger(repo.owner, repo.name)}
- disabled={!isEnabled || isTriggering || image?.status === "building"}
- title="Rebuild image"
- >
-
-
+ {canManage && (
+ handleTrigger(repo.owner, repo.name)}
+ disabled={!isEnabled || isTriggering || image?.status === "building"}
+ title="Rebuild image"
+ >
+
+
+ )}
);
diff --git a/packages/web/src/components/settings/integrations/commit-signing-settings.test.tsx b/packages/web/src/components/settings/integrations/commit-signing-settings.test.tsx
index 7c0c11f69..24ce403b4 100644
--- a/packages/web/src/components/settings/integrations/commit-signing-settings.test.tsx
+++ b/packages/web/src/components/settings/integrations/commit-signing-settings.test.tsx
@@ -8,6 +8,10 @@ import * as matchers from "@testing-library/jest-dom/matchers";
import { CommitSigningSettings } from "./commit-signing-settings";
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({ hasPermission: () => true }),
+}));
+
expect.extend(matchers);
const { useSWRMock, mutateMock } = vi.hoisted(() => ({
diff --git a/packages/web/src/components/settings/integrations/commit-signing-settings.tsx b/packages/web/src/components/settings/integrations/commit-signing-settings.tsx
index b3564457f..9271e8ca5 100644
--- a/packages/web/src/components/settings/integrations/commit-signing-settings.tsx
+++ b/packages/web/src/components/settings/integrations/commit-signing-settings.tsx
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const SETTINGS_KEY = "/api/commit-signing";
@@ -22,7 +23,12 @@ const STATUS_LABELS: Record = {
enabled: "Configured",
};
+/**
+ * Displays commit-signing settings and makes the configuration read-only without management permission.
+ */
export function CommitSigningSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManage = hasPermission("commit_signing.manage");
const { data: rawData, error, isLoading, mutate } = useSWR(SETTINGS_KEY);
const viewState = useMemo(() => {
if (isLoading) return { kind: "loading" } as const;
@@ -140,7 +146,7 @@ export function CommitSigningSettings() {
)}
-
+
Committer name
)}
-
+
);
}
diff --git a/packages/web/src/components/settings/integrations/enablement-integration-settings.test.tsx b/packages/web/src/components/settings/integrations/enablement-integration-settings.test.tsx
index 4c1c76478..25f376f0a 100644
--- a/packages/web/src/components/settings/integrations/enablement-integration-settings.test.tsx
+++ b/packages/web/src/components/settings/integrations/enablement-integration-settings.test.tsx
@@ -9,6 +9,15 @@ import type { EnrichedRepository } from "@open-inspect/shared/types/repository-c
import { CodeServerIntegrationSettings } from "./code-server-integration-settings";
import { VncIntegrationSettings } from "./vnc-integration-settings";
+const allowedPermissions = vi.hoisted(() => ({ value: null as Set | null }));
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ allowedPermissions.value === null || allowedPermissions.value.has(permission),
+ }),
+}));
+
expect.extend(matchers);
const { useSWRMock, mutateMock } = vi.hoisted(() => ({
@@ -103,6 +112,7 @@ beforeEach(() => {
toastError.mockReset();
mutateMock.mockReset();
useSWRMock.mockReset();
+ allowedPermissions.value = null;
vi.stubGlobal("fetch", fetchMock);
});
@@ -135,6 +145,21 @@ describe("code-server enablement integration settings", () => {
);
});
+ it("separates global and repository mutation permissions", () => {
+ setupSWR(id, {
+ global: { defaults: { enabled: true } },
+ repos: [{ repo: nestedRepo, settings: { enabled: true } }],
+ });
+ allowedPermissions.value = new Set(["integrations.read", "repositories.settings.manage"]);
+
+ render( );
+
+ expect(screen.getByRole("checkbox", { name: new RegExp(`^${enableLabel}`) })).toBeDisabled();
+ expect(
+ within(overrideRow(nestedRepo)).getByRole("checkbox", { name: /enabled/i })
+ ).toBeEnabled();
+ });
+
it("resets global settings", async () => {
const user = userEvent.setup();
setupSWR(id, { global: { defaults: { enabled: true } } });
diff --git a/packages/web/src/components/settings/integrations/enablement-integration-settings.tsx b/packages/web/src/components/settings/integrations/enablement-integration-settings.tsx
index e6b322a08..9ce6d7969 100644
--- a/packages/web/src/components/settings/integrations/enablement-integration-settings.tsx
+++ b/packages/web/src/components/settings/integrations/enablement-integration-settings.tsx
@@ -31,6 +31,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
interface EnablementSettings {
enabled?: boolean;
@@ -70,7 +71,13 @@ interface ReposResponse {
repos: EnrichedRepository[];
}
+/**
+ * Renders global and repository enablement settings with each scope editable only by authorized users.
+ */
export function EnablementIntegrationSettings({ copy }: { copy: EnablementIntegrationCopy }) {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManageGlobal = hasPermission("integrations.manage");
+ const canManageRepos = hasPermission("repositories.settings.manage");
const globalSettingsKey = `/api/integration-settings/${copy.id}` as const;
const repoSettingsKey = `/api/integration-settings/${copy.id}/repos` as const;
const { data: globalData, isLoading: globalLoading } = useSWR(globalSettingsKey);
@@ -91,20 +98,24 @@ export function EnablementIntegrationSettings({ copy }: { copy: EnablementIntegr
{copy.title}
{copy.intro}
-
-
-
-
+
+
+
+
+
+
+
);
diff --git a/packages/web/src/components/settings/integrations/github-integration-settings.test.tsx b/packages/web/src/components/settings/integrations/github-integration-settings.test.tsx
index b63475b70..5ab884cb5 100644
--- a/packages/web/src/components/settings/integrations/github-integration-settings.test.tsx
+++ b/packages/web/src/components/settings/integrations/github-integration-settings.test.tsx
@@ -13,6 +13,10 @@ import {
} from "@open-inspect/shared/types/integrations";
import { GitHubIntegrationSettings } from "./github-integration-settings";
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({ hasPermission: () => true }),
+}));
+
expect.extend(matchers);
interface RepoSettingsEntry {
diff --git a/packages/web/src/components/settings/integrations/github-integration-settings.tsx b/packages/web/src/components/settings/integrations/github-integration-settings.tsx
index c78c39006..5756e26d8 100644
--- a/packages/web/src/components/settings/integrations/github-integration-settings.tsx
+++ b/packages/web/src/components/settings/integrations/github-integration-settings.tsx
@@ -12,6 +12,7 @@ import { CommitSigningSettings } from "./commit-signing-settings";
import { GlobalSettingsSection } from "./github-global-settings-section";
import { RepoOverridesSection, type RepoSettingsEntry } from "./github-repo-overrides-section";
import { IntegrationSettingsSection } from "./integration-settings-section";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const GLOBAL_SETTINGS_KEY = "/api/integration-settings/github";
const REPO_SETTINGS_KEY = "/api/integration-settings/github/repos";
@@ -28,7 +29,13 @@ interface ReposResponse {
repos: EnrichedRepository[];
}
+/**
+ * Displays GitHub integration settings with global and repository edits gated by their respective permissions.
+ */
export function GitHubIntegrationSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManageGlobal = hasPermission("integrations.manage");
+ const canManageRepos = hasPermission("repositories.settings.manage");
const { data: globalData, isLoading: globalLoading } =
useSWR(GLOBAL_SETTINGS_KEY);
const { data: repoSettingsData, isLoading: repoSettingsLoading } =
@@ -74,23 +81,27 @@ export function GitHubIntegrationSettings() {
-
+
+
+
-
+
+
+
);
diff --git a/packages/web/src/components/settings/integrations/linear-integration-settings.tsx b/packages/web/src/components/settings/integrations/linear-integration-settings.tsx
index eb27e3ecd..91c53664d 100644
--- a/packages/web/src/components/settings/integrations/linear-integration-settings.tsx
+++ b/packages/web/src/components/settings/integrations/linear-integration-settings.tsx
@@ -46,6 +46,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ModelReasoningDefaultsFields } from "./model-reasoning-defaults-fields";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const GLOBAL_SETTINGS_KEY = "/api/integration-settings/linear";
const REPO_SETTINGS_KEY = "/api/integration-settings/linear/repos";
@@ -67,7 +68,13 @@ interface ReposResponse {
repos: EnrichedRepository[];
}
+/**
+ * Displays Linear integration settings with global and repository edits gated by their respective permissions.
+ */
export function LinearIntegrationSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManageGlobal = hasPermission("integrations.manage");
+ const canManageRepos = hasPermission("repositories.settings.manage");
const { data: globalData, isLoading: globalLoading } =
useSWR(GLOBAL_SETTINGS_KEY);
const { data: repoSettingsData, isLoading: repoSettingsLoading } =
@@ -108,21 +115,25 @@ export function LinearIntegrationSettings() {
)}
-
+
+
+
-
+
+
+
);
diff --git a/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx b/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx
index 21b2d5829..b9cdf21ee 100644
--- a/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx
+++ b/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx
@@ -14,6 +14,10 @@ import {
} from "@open-inspect/shared/types/integrations";
import { SlackIntegrationSettings } from "./slack-integration-settings";
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({ hasPermission: () => true }),
+}));
+
expect.extend(matchers);
interface RepoSettingsEntry {
diff --git a/packages/web/src/components/settings/integrations/slack-integration-settings.tsx b/packages/web/src/components/settings/integrations/slack-integration-settings.tsx
index b78d378ee..51db92585 100644
--- a/packages/web/src/components/settings/integrations/slack-integration-settings.tsx
+++ b/packages/web/src/components/settings/integrations/slack-integration-settings.tsx
@@ -53,6 +53,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const GLOBAL_SETTINGS_KEY = "/api/integration-settings/slack";
const REPO_SETTINGS_KEY = "/api/integration-settings/slack/repos";
@@ -114,7 +115,13 @@ function mergedGlobalDefaults(
return defaults;
}
+/**
+ * Displays Slack integration settings with global and repository edits gated by their respective permissions.
+ */
export function SlackIntegrationSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManageGlobal = hasPermission("integrations.manage");
+ const canManageRepos = hasPermission("repositories.settings.manage");
const { data: globalData, isLoading: globalLoading } =
useSWR(GLOBAL_SETTINGS_KEY);
const { data: repoSettingsData, isLoading: repoSettingsLoading } =
@@ -154,21 +161,27 @@ export function SlackIntegrationSettings() {
-
-
-
+
+
+
+
+
+
+
-
+
+
+
);
diff --git a/packages/web/src/components/settings/mcp-servers-settings.test.tsx b/packages/web/src/components/settings/mcp-servers-settings.test.tsx
index c59cb9a8d..0df7e49f7 100644
--- a/packages/web/src/components/settings/mcp-servers-settings.test.tsx
+++ b/packages/web/src/components/settings/mcp-servers-settings.test.tsx
@@ -13,6 +13,7 @@ expect.extend(matchers);
const mocks = vi.hoisted(() => ({
mutate: vi.fn(),
updateMcpServer: vi.fn(),
+ allowedPermissions: null as Set | null,
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
@@ -25,6 +26,12 @@ vi.mock("@/hooks/use-mcp-servers", () => ({
updateMcpServer: mocks.updateMcpServer,
deleteMcpServer: vi.fn(),
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ mocks.allowedPermissions === null || mocks.allowedPermissions.has(permission),
+ }),
+}));
const servers: McpServerMetadata[] = [
{
@@ -54,9 +61,22 @@ const servers: McpServerMetadata[] = [
afterEach(() => {
cleanup();
vi.clearAllMocks();
+ mocks.allowedPermissions = null;
});
describe("McpServersSettings", () => {
+ it("shows servers but no mutation entry points with read-only permission", () => {
+ mocks.allowedPermissions = new Set(["mcp_servers.read"]);
+
+ render( );
+
+ expect(screen.getByText("Server A")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Add Server" })).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Server A/ })).toBeDisabled();
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
+ });
+
it("does not close a newer draft when an older save completes", async () => {
let resolveSave!: (server: McpServerMetadata) => void;
mocks.updateMcpServer.mockReturnValue(
diff --git a/packages/web/src/components/settings/mcp-servers-settings.tsx b/packages/web/src/components/settings/mcp-servers-settings.tsx
index d8bf0fd75..594042369 100644
--- a/packages/web/src/components/settings/mcp-servers-settings.tsx
+++ b/packages/web/src/components/settings/mcp-servers-settings.tsx
@@ -32,6 +32,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
type ScopeMode = "global" | "selected";
type Editor =
@@ -438,7 +439,12 @@ function McpServerForm({
);
}
+/**
+ * Lists workspace MCP servers and exposes create, edit, and delete controls only to authorized users.
+ */
export function McpServersSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManage = hasPermission("mcp_servers.manage");
const { servers, loading, mutate } = useMcpServers();
const { repos, loading: loadingRepos } = useRepos();
const [editor, setEditor] = useState(null);
@@ -564,12 +570,14 @@ export function McpServersSettings() {
MCP Servers
-
-
-
- Add Server
-
-
+ {canManage && (
+
+
+
+ Add Server
+
+
+ )}
Configure Model Context Protocol servers that are available to agent sessions.
@@ -635,7 +643,8 @@ export function McpServersSettings() {
startEdit(server)}
+ onClick={() => canManage && startEdit(server)}
+ disabled={!canManage}
>
-
- handleToggle(server)}
- aria-label={server.enabled ? "Disable" : "Enable"}
- />
- setDeleteTarget(server.id)}
- className="px-2 py-1 text-xs text-destructive hover:text-destructive/80 transition"
- >
- Delete
-
-
+ {canManage && (
+
+ handleToggle(server)}
+ aria-label={server.enabled ? "Disable" : "Enable"}
+ />
+ setDeleteTarget(server.id)}
+ className="px-2 py-1 text-xs text-destructive hover:text-destructive/80 transition"
+ >
+ Delete
+
+
+ )}
{/* Expanded edit form */}
@@ -713,7 +724,7 @@ export function McpServersSettings() {
)}
{/* Delete confirmation dialog */}
- setDeleteTarget(null)}>
+ setDeleteTarget(null)}>
Delete MCP server
diff --git a/packages/web/src/components/settings/provider-accounts-settings.test.tsx b/packages/web/src/components/settings/provider-accounts-settings.test.tsx
index 763fd18dc..28fb17384 100644
--- a/packages/web/src/components/settings/provider-accounts-settings.test.tsx
+++ b/packages/web/src/components/settings/provider-accounts-settings.test.tsx
@@ -43,6 +43,14 @@ const account = {
};
let accountsResult: ModelProviderAccount[];
let defaultsResult: ModelProviderAccountDefault[];
+let allowedPermissions: Set | null;
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ allowedPermissions === null || allowedPermissions.has(permission),
+ }),
+}));
vi.mock("@/hooks/use-provider-accounts", () => ({
useProviderAccounts: () => ({
@@ -88,6 +96,7 @@ describe("ProviderAccountsSettings", () => {
reconnectAccount.mockResolvedValue(undefined);
accountsResult = [account];
defaultsResult = [];
+ allowedPermissions = null;
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
@@ -98,6 +107,18 @@ describe("ProviderAccountsSettings", () => {
};
});
+ it("keeps read-only account details visible without management actions", () => {
+ allowedPermissions = new Set(["provider_accounts.read"]);
+
+ render( );
+
+ expect(screen.getByText("Team ChatGPT")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Add account" })).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "More actions for Team ChatGPT" })
+ ).not.toBeInTheDocument();
+ });
+
it("reconnects OpenAI through device authorization with the selected account id", async () => {
render( );
fireEvent.pointerDown(screen.getByRole("button", { name: "More actions for Team ChatGPT" }), {
diff --git a/packages/web/src/components/settings/provider-accounts-settings.tsx b/packages/web/src/components/settings/provider-accounts-settings.tsx
index 0a7c76498..d34fbb1d6 100644
--- a/packages/web/src/components/settings/provider-accounts-settings.tsx
+++ b/packages/web/src/components/settings/provider-accounts-settings.tsx
@@ -54,6 +54,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
type Confirm = { account: ModelProviderAccount; action: "disable" | "archive" } | null;
type Connection =
@@ -174,7 +175,12 @@ function LegacyReconnectForm({
);
}
+/**
+ * Displays provider accounts while restricting connection and account-management actions by permission.
+ */
export function ProviderAccountsSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManage = hasPermission("provider_accounts.manage");
const { providers, accounts, defaults, loading, error, refresh } = useProviderAccounts();
const legacyCredentials = useLegacyProviderCredentials();
const [connection, setConnection] = useState(null);
@@ -256,32 +262,34 @@ export function ProviderAccountsSettings() {
Connected accounts
-
-
-
-
- Add account
-
-
-
- Subscriptions
- {providers.map((provider) => (
-
- beginConnection(CONNECTION_STRATEGIES[provider.provider].add())
- }
- >
-
- {provider.subscriptionName}
-
- ))}
-
-
+ {canManage && (
+
+
+
+
+ Add account
+
+
+
+ Subscriptions
+ {providers.map((provider) => (
+
+ beginConnection(CONNECTION_STRATEGIES[provider.provider].add())
+ }
+ >
+
+ {provider.subscriptionName}
+
+ ))}
+
+
+ )}
{accounts.length === 0 ? (
@@ -356,135 +364,139 @@ export function ProviderAccountsSettings() {
-
- {account.status === "reconnect_required" && (
-
- beginConnection(
- CONNECTION_STRATEGIES[account.provider].reconnect(account)
- )
- }
- >
- Reconnect
-
- )}
- {account.status === "disabled" && (
-
- void run(
- () => runProviderAccountAction(account.id, "enable"),
- "Account enabled"
- )
- }
- >
- Enable
-
- )}
-
-
+ {canManage && (
+
+ {account.status === "reconnect_required" && (
+ beginConnection(
+ CONNECTION_STRATEGIES[account.provider].reconnect(account)
+ )
+ }
>
-
+ Reconnect
-
-
- {account.status !== "reconnect_required" && (
-
- beginConnection(
- CONNECTION_STRATEGIES[account.provider].reconnect(account)
- )
- }
- >
- Reconnect
-
- )}
-
+ )}
+ {account.status === "disabled" && (
+
void run(
- () => runProviderAccountAction(account.id, "verify"),
- "Account verified"
+ () => runProviderAccountAction(account.id, "enable"),
+ "Account enabled"
)
}
>
- Verify
-
- {account.status === "active" && !isDefault && (
-
+ )}
+
+
+
+
+
+
+
+ {account.status !== "reconnect_required" && (
+
+ beginConnection(
+ CONNECTION_STRATEGIES[account.provider].reconnect(account)
+ )
+ }
+ >
+ Reconnect
+
+ )}
+
void run(
- () =>
- setProviderAccountDefault(
- account.provider,
- account.id,
- providerDefault?.unattendedMode ?? "provider_account"
- ),
- "Default updated"
+ () => runProviderAccountAction(account.id, "verify"),
+ "Account verified"
)
}
>
- Make default
+ Verify
- )}
- {
- if (operationInFlightRef.current) return;
- const displayName = window
- .prompt("Account name", account.displayName)
- ?.trim();
- if (displayName)
- void run(
- () => renameProviderAccount(account.id, displayName),
- "Account renamed"
- );
- }}
- >
- Rename
-
- {externalAccountId && (
+ {account.status === "active" && !isDefault && (
+
+ void run(
+ () =>
+ setProviderAccountDefault(
+ account.provider,
+ account.id,
+ providerDefault?.unattendedMode ?? "provider_account"
+ ),
+ "Default updated"
+ )
+ }
+ >
+ Make default
+
+ )}
- void navigator.clipboard
- .writeText(externalAccountId)
- .then(() => toast.success("Account ID copied"))
- .catch(() => toast.error("Failed to copy account ID"))
- }
+ disabled={saving}
+ onSelect={() => {
+ if (operationInFlightRef.current) return;
+ const displayName = window
+ .prompt("Account name", account.displayName)
+ ?.trim();
+ if (displayName)
+ void run(
+ () => renameProviderAccount(account.id, displayName),
+ "Account renamed"
+ );
+ }}
>
- Copy account ID
+ Rename
- )}
-
- {account.status === "active" && (
+ {externalAccountId && (
+
+ void navigator.clipboard
+ .writeText(externalAccountId)
+ .then(() => toast.success("Account ID copied"))
+ .catch(() => toast.error("Failed to copy account ID"))
+ }
+ >
+ Copy account ID
+
+ )}
+
+ {account.status === "active" && (
+
+ beginConfirmation({ account, action: "disable" })
+ }
+ >
+ Disable
+
+ )}
beginConfirmation({ account, action: "disable" })}
+ onSelect={() => beginConfirmation({ account, action: "archive" })}
>
- Disable
+ Archive
- )}
- beginConfirmation({ account, action: "archive" })}
- >
- Archive
-
-
-
-
+
+
+
+ )}
{account.status !== "active" && (
{
if (!operationInFlightRef.current)
@@ -586,7 +598,7 @@ export function ProviderAccountsSettings() {
>
)}
- {connection?.kind === "device" && (
+ {canManage && connection?.kind === "device" && (
)}
- {connection?.kind === "legacy-xai" && (
+ {canManage && connection?.kind === "legacy-xai" && (
)}
- !open && setConfirm(null)}>
+ !open && setConfirm(null)}>
diff --git a/packages/web/src/components/settings/sandbox-settings.test.tsx b/packages/web/src/components/settings/sandbox-settings.test.tsx
index 6053d95b2..40a146081 100644
--- a/packages/web/src/components/settings/sandbox-settings.test.tsx
+++ b/packages/web/src/components/settings/sandbox-settings.test.tsx
@@ -14,6 +14,10 @@ import {
} from "@open-inspect/shared/types/integrations";
import { SandboxSettingsEditor, SandboxSettingsPage } from "./sandbox-settings";
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({ hasPermission: () => true }),
+}));
+
expect.extend(matchers);
const reposMock = vi.hoisted(() => ({
diff --git a/packages/web/src/components/settings/sandbox-settings.tsx b/packages/web/src/components/settings/sandbox-settings.tsx
index 7d28155d6..16be4d3e7 100644
--- a/packages/web/src/components/settings/sandbox-settings.tsx
+++ b/packages/web/src/components/settings/sandbox-settings.tsx
@@ -29,6 +29,7 @@ import {
sandboxTimeoutMinutesFromMs,
sandboxTimeoutMsFromMinutes,
} from "./sandbox-timeout";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const GLOBAL_SCOPE = "__global__";
type ResourceField = "cpuCores" | "memoryMib";
@@ -217,6 +218,9 @@ function useSandboxSettingsScope(
};
}
+/**
+ * Edits inherited sandbox settings for one scope, becoming read-only without that scope's management permission.
+ */
export function SandboxSettingsEditor({
scope,
owner,
@@ -233,7 +237,15 @@ export function SandboxSettingsEditor({
name?: string;
environmentId?: string;
}) {
+ const { hasPermission } = useCurrentUserAuthorization();
const isGlobal = scope === "global";
+ const canManage = hasPermission(
+ scope === "global"
+ ? "integrations.manage"
+ : scope === "repo"
+ ? "repositories.settings.manage"
+ : "environments.settings.manage"
+ );
const { apiUrl, ownSettings, baseDefaults, enabledRepos, isLoading, mutate } =
useSandboxSettingsScope(scope, owner, name, environmentId);
@@ -585,7 +597,7 @@ export function SandboxSettingsEditor({
}
return (
-
+
{/* Web Terminal toggle */}
@@ -876,7 +888,7 @@ export function SandboxSettingsEditor({
{success && Saved }
-
+
);
}
diff --git a/packages/web/src/components/settings/scm-settings.test.tsx b/packages/web/src/components/settings/scm-settings.test.tsx
index 1508dfc13..672561181 100644
--- a/packages/web/src/components/settings/scm-settings.test.tsx
+++ b/packages/web/src/components/settings/scm-settings.test.tsx
@@ -10,6 +10,10 @@ import type { EnrichedRepository } from "@open-inspect/shared/types/repository-c
import { parseRepositoryFullName } from "@open-inspect/shared/types/repositories";
import { ScmSettingsPage } from "./scm-settings";
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({ hasPermission: () => true }),
+}));
+
expect.extend(matchers);
interface RepoSettingsEntry {
diff --git a/packages/web/src/components/settings/scm-settings.tsx b/packages/web/src/components/settings/scm-settings.tsx
index 1dc7d0673..1750f207d 100644
--- a/packages/web/src/components/settings/scm-settings.tsx
+++ b/packages/web/src/components/settings/scm-settings.tsx
@@ -36,6 +36,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const DEFAULT_ALWAYS_USE_DRAFT_MODE = false;
const DEFAULT_PULL_REQUEST_LABEL = "";
@@ -98,7 +99,12 @@ function isRepoListResponse(value: unknown): value is RepoListResponse {
);
}
+/**
+ * Displays source-control defaults and disables editing for users without SCM settings management permission.
+ */
export function ScmSettingsPage() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManage = hasPermission("scm_settings.manage");
const {
data: globalData,
error: globalError,
@@ -141,18 +147,22 @@ export function ScmSettingsPage() {
Defaults for pull and merge requests opened by coding sessions.
-
+
+
+
-
+
+
+
);
diff --git a/packages/web/src/components/settings/secrets-settings.test.tsx b/packages/web/src/components/settings/secrets-settings.test.tsx
new file mode 100644
index 000000000..08cdffa66
--- /dev/null
+++ b/packages/web/src/components/settings/secrets-settings.test.tsx
@@ -0,0 +1,110 @@
+// @vitest-environment jsdom
+///
+
+import { cleanup, render, screen } from "@testing-library/react";
+import * as matchers from "@testing-library/jest-dom/matchers";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { SecretsSettings } from "./secrets-settings";
+
+expect.extend(matchers);
+
+Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
+ configurable: true,
+ value: vi.fn(),
+});
+
+const mocks = vi.hoisted(() => ({
+ permissions: new Set(),
+ useRepos: vi.fn(),
+}));
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) => mocks.permissions.has(permission),
+ }),
+}));
+vi.mock("@/hooks/use-repos", () => ({ useRepos: mocks.useRepos }));
+vi.mock("@/components/secrets-editor", () => ({
+ SecretsEditor: ({ scope, owner, name }: { scope: string; owner?: string; name?: string }) => (
+ {`${scope} secrets editor${owner && name ? ` for ${owner}/${name}` : ""}`}
+ ),
+}));
+
+afterEach(() => {
+ cleanup();
+ mocks.permissions = new Set();
+ mocks.useRepos.mockReset();
+});
+
+describe("SecretsSettings", () => {
+ it("keeps the existing global default for users with both permissions", () => {
+ mocks.permissions = new Set([
+ "global_secrets.manage",
+ "repositories.secrets.manage",
+ "repositories.read",
+ ]);
+ mocks.useRepos.mockReturnValue({ repos: [], loading: false });
+
+ render( );
+
+ expect(mocks.useRepos).toHaveBeenCalledWith(true);
+ expect(screen.getByText("global secrets editor")).toBeInTheDocument();
+ expect(screen.getByText("All Repositories (Global)")).toBeInTheDocument();
+ });
+
+ it("does not fetch repositories or mount a repo editor for global-only users", () => {
+ mocks.permissions = new Set(["global_secrets.manage"]);
+ mocks.useRepos.mockReturnValue({ repos: [], loading: false });
+
+ render( );
+
+ expect(mocks.useRepos).toHaveBeenCalledWith(false);
+ expect(screen.getByText("global secrets editor")).toBeInTheDocument();
+ expect(screen.queryByText("repo secrets editor")).not.toBeInTheDocument();
+ });
+
+ it("lets authorized repository managers select a repository", async () => {
+ const user = userEvent.setup();
+ mocks.permissions = new Set(["repositories.secrets.manage", "repositories.read"]);
+ mocks.useRepos.mockReturnValue({
+ repos: [
+ {
+ id: 1,
+ fullName: "open-inspect/background-agents",
+ owner: "open-inspect",
+ name: "background-agents",
+ description: null,
+ private: true,
+ defaultBranch: "main",
+ },
+ ],
+ loading: false,
+ });
+
+ render( );
+
+ expect(mocks.useRepos).toHaveBeenCalledWith(true);
+ expect(screen.getByText("repo secrets editor")).toBeInTheDocument();
+ expect(screen.queryByText("global secrets editor")).not.toBeInTheDocument();
+ expect(screen.queryByText("All Repositories (Global)")).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Repository Select a repository" }));
+ await user.click(screen.getByRole("option", { name: /background-agents/ }));
+
+ expect(
+ screen.getByText("repo secrets editor for open-inspect/background-agents")
+ ).toBeInTheDocument();
+ });
+
+ it("does not fetch or offer repository scope without repository read access", () => {
+ mocks.permissions = new Set(["repositories.secrets.manage"]);
+ mocks.useRepos.mockReturnValue({ repos: [], loading: false });
+
+ render( );
+
+ expect(mocks.useRepos).toHaveBeenCalledWith(false);
+ expect(screen.queryByText("repo secrets editor")).not.toBeInTheDocument();
+ expect(screen.queryByText("global secrets editor")).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/web/src/components/settings/secrets-settings.tsx b/packages/web/src/components/settings/secrets-settings.tsx
index b4421b93f..dcf2b4913 100644
--- a/packages/web/src/components/settings/secrets-settings.tsx
+++ b/packages/web/src/components/settings/secrets-settings.tsx
@@ -5,12 +5,20 @@ import { useRepos } from "@/hooks/use-repos";
import { useState } from "react";
import { ChevronDownIcon, CheckIcon } from "@/components/ui/icons";
import { Combobox } from "@/components/ui/combobox";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
const GLOBAL_SCOPE = "__global__";
+/**
+ * Exposes global and repository secret editors only for scopes the user is authorized to manage.
+ */
export function SecretsSettings() {
- const { repos, loading: loadingRepos } = useRepos();
- const [selectedRepo, setSelectedRepo] = useState(GLOBAL_SCOPE);
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canManageGlobal = hasPermission("global_secrets.manage");
+ const canManageRepos =
+ hasPermission("repositories.secrets.manage") && hasPermission("repositories.read");
+ const { repos, loading: loadingRepos } = useRepos(canManageRepos);
+ const [selectedRepo, setSelectedRepo] = useState(canManageGlobal ? GLOBAL_SCOPE : "");
const selectedRepoObj = repos.find((r) => r.fullName === selectedRepo);
const isGlobal = selectedRepo === GLOBAL_SCOPE;
@@ -43,7 +51,7 @@ export function SecretsSettings() {
labelId="secrets-repository-label"
value={selectedRepo}
onChange={setSelectedRepo}
- items={repos.map((repo) => ({
+ items={(canManageRepos ? repos : []).map((repo) => ({
value: repo.fullName,
label: repo.name,
description: `${repo.owner}${repo.private ? " \u2022 private" : ""}`,
@@ -57,44 +65,46 @@ export function SecretsSettings() {
}
direction="down"
dropdownWidth="w-full max-w-sm"
- disabled={loadingRepos}
+ disabled={loadingRepos || (!canManageGlobal && !canManageRepos)}
triggerClassName="w-full max-w-sm flex items-center justify-between px-3 py-2 text-sm border border-border bg-input text-foreground hover:border-foreground/30 disabled:opacity-50 disabled:cursor-not-allowed transition"
- prependContent={({ select }) => (
- <>
- select(GLOBAL_SCOPE)}
- className={`w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-muted transition ${
- isGlobal ? "text-foreground" : "text-muted-foreground"
- }`}
- >
-
- All Repositories (Global)
-
- Shared across all repositories
-
-
- {isGlobal && }
-
- {repos.length > 0 &&
}
- >
- )}
+ prependContent={({ select }) =>
+ canManageGlobal ? (
+ <>
+ select(GLOBAL_SCOPE)}
+ className={`w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-muted transition ${
+ isGlobal ? "text-foreground" : "text-muted-foreground"
+ }`}
+ >
+
+ All Repositories (Global)
+
+ Shared across all repositories
+
+
+ {isGlobal && }
+
+ {repos.length > 0 &&
}
+ >
+ ) : null
+ }
>
{displayRepoName}
- {isGlobal ? (
-
- ) : (
+ {isGlobal && canManageGlobal ? (
+
+ ) : canManageRepos ? (
- )}
+ ) : null}
);
}
diff --git a/packages/web/src/components/settings/settings-nav.test.tsx b/packages/web/src/components/settings/settings-nav.test.tsx
index 8adec9ddc..9f5c9c6c8 100644
--- a/packages/web/src/components/settings/settings-nav.test.tsx
+++ b/packages/web/src/components/settings/settings-nav.test.tsx
@@ -8,22 +8,32 @@ import type { ComponentProps } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SettingsNav } from "./settings-nav";
import { SettingsViewportProvider } from "./settings-viewport-context";
+import { resolveSettingsCategory } from "./settings-registry";
expect.extend(matchers);
const mocks = vi.hoisted(() => ({
isMobile: false,
repoImagesEnabled: true,
+ allowedPermissions: null as Set | null,
}));
vi.mock("@/lib/sandbox-provider", () => ({
supportsRepoImages: () => mocks.repoImagesEnabled,
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ mocks.allowedPermissions === null || mocks.allowedPermissions.has(permission),
+ }),
+}));
+
afterEach(() => {
cleanup();
mocks.isMobile = false;
mocks.repoImagesEnabled = true;
+ mocks.allowedPermissions = null;
});
function renderSettingsNav(
@@ -39,6 +49,20 @@ function renderSettingsNav(
}
describe("SettingsNav", () => {
+ it("resolves defaults and deep links to an authorized category", () => {
+ const hasNoWorkspacePermissions = () => false;
+
+ expect(resolveSettingsCategory(null, true, hasNoWorkspacePermissions)).toBe("appearance");
+ expect(resolveSettingsCategory("secrets", true, hasNoWorkspacePermissions)).toBe("appearance");
+ expect(
+ resolveSettingsCategory(
+ "environments",
+ true,
+ (permission) => permission === "environments.read"
+ )
+ ).toBe("environments");
+ });
+
it("groups settings and filters labels, descriptions, and keywords", async () => {
const user = userEvent.setup();
renderSettingsNav({ activeCategory: "appearance" });
@@ -86,4 +110,39 @@ describe("SettingsNav", () => {
expect(screen.queryByRole("button", { name: "Images" })).not.toBeInTheDocument();
});
+
+ it("hides settings that require unavailable permissions", () => {
+ mocks.allowedPermissions = new Set();
+ renderSettingsNav({ activeCategory: "appearance" });
+
+ expect(screen.getByRole("button", { name: "Appearance" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Secrets" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Workspace access" })).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["global secret management", ["global_secrets.manage"]],
+ ["repository read and secret management", ["repositories.read", "repositories.secrets.manage"]],
+ ])("shows secrets with %s", (_description, permissions) => {
+ mocks.allowedPermissions = new Set(permissions);
+ renderSettingsNav({ activeCategory: "secrets" });
+
+ expect(screen.getByRole("button", { name: "Secrets" })).toBeInTheDocument();
+ });
+
+ it("hides secrets from repository secret managers without repository read access", () => {
+ mocks.allowedPermissions = new Set(["repositories.secrets.manage"]);
+ renderSettingsNav({ activeCategory: "appearance" });
+
+ expect(screen.queryByRole("button", { name: "Secrets" })).not.toBeInTheDocument();
+ });
+
+ it("keeps read-level sandbox and environment panels visible", () => {
+ mocks.allowedPermissions = new Set(["environments.read", "integrations.read"]);
+ renderSettingsNav({ activeCategory: "environments" });
+
+ expect(screen.getByRole("button", { name: "Environments" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Sandbox" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Integrations" })).toBeInTheDocument();
+ });
});
diff --git a/packages/web/src/components/settings/settings-nav.tsx b/packages/web/src/components/settings/settings-nav.tsx
index 38f734dc5..028f469eb 100644
--- a/packages/web/src/components/settings/settings-nav.tsx
+++ b/packages/web/src/components/settings/settings-nav.tsx
@@ -5,6 +5,7 @@ import { useState } from "react";
import { BackIcon, ChevronRightIcon, SearchIcon } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
import { useSettingsIsMobile } from "./settings-viewport-context";
import { getSettingsGroups, type SettingsCategory } from "./settings-registry";
@@ -38,10 +39,14 @@ function SettingsSearch({ value, onChange }: { value: string; onChange: (value:
);
}
+/**
+ * Renders searchable settings navigation containing only categories the current user may access.
+ */
export function SettingsNav({ activeCategory, onSelect }: SettingsNavProps) {
const isMobile = useSettingsIsMobile();
const [query, setQuery] = useState("");
- const groups = getSettingsGroups({ query });
+ const { hasPermission } = useCurrentUserAuthorization();
+ const groups = getSettingsGroups({ query, hasPermission });
const navigation = (
diff --git a/packages/web/src/components/settings/settings-registry.test.ts b/packages/web/src/components/settings/settings-registry.test.ts
new file mode 100644
index 000000000..df2d9bc0f
--- /dev/null
+++ b/packages/web/src/components/settings/settings-registry.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import {
+ SETTINGS_GROUPS,
+ canViewSettingsCategory,
+ getSettingsPanel,
+ type SettingsCategory,
+} from "./settings-registry";
+
+describe("settings registry", () => {
+ it("registers every category exactly once with a panel and explicit visibility", () => {
+ const categories: string[] = [];
+
+ for (const group of SETTINGS_GROUPS) {
+ for (const item of group.items) {
+ categories.push(item.id);
+ expect(getSettingsPanel(item.id)).toBe(item.panel);
+ expect("public" in item.visibility || "anyOf" in item.visibility).toBe(true);
+ expect("public" in item.visibility && "anyOf" in item.visibility).toBe(false);
+ }
+ }
+ expect(new Set(categories).size).toBe(categories.length);
+ });
+
+ it.each([
+ [["global_secrets.manage"], true],
+ [["repositories.secrets.manage"], false],
+ [["repositories.read"], false],
+ [["repositories.secrets.manage", "repositories.read"], true],
+ [["integrations.read"], false],
+ ] as const)("resolves secrets visibility for %s", (permissions, expected) => {
+ expect(
+ canViewSettingsCategory("secrets", (candidate) => permissions.some((p) => p === candidate))
+ ).toBe(expected);
+ });
+
+ it("keeps public categories independent of permissions", () => {
+ const publicCategories: SettingsCategory[] = ["appearance", "keyboard-shortcuts"];
+
+ for (const category of publicCategories) {
+ expect(canViewSettingsCategory(category, () => false)).toBe(true);
+ }
+ });
+});
diff --git a/packages/web/src/components/settings/settings-registry.ts b/packages/web/src/components/settings/settings-registry.ts
index 2eb1e31e2..b5c77ebca 100644
--- a/packages/web/src/components/settings/settings-registry.ts
+++ b/packages/web/src/components/settings/settings-registry.ts
@@ -12,8 +12,44 @@ import {
TerminalIcon,
} from "@/components/ui/icons";
import { supportsRepoImages } from "@/lib/sandbox-provider";
+import type { PermissionId } from "@open-inspect/shared/rbac";
import { matchesSearchTerms } from "@/lib/search";
+import { lazy, type ComponentType, type LazyExoticComponent } from "react";
+type SettingsPermissionPredicate = PermissionId | { allOf: readonly PermissionId[] };
+type SettingsVisibility = { public: true } | { anyOf: readonly SettingsPermissionPredicate[] };
+
+interface SettingsItemDefinition {
+ id: string;
+ label: string;
+ description: string;
+ keywords: string;
+ icon: ComponentType<{ className?: string }>;
+ visibility: SettingsVisibility;
+ panel: LazyExoticComponent
;
+ requiresRepoImages?: boolean;
+}
+
+interface SettingsGroupDefinition {
+ label: string;
+ items: readonly SettingsItemDefinition[];
+}
+
+const publicSettings = { public: true } as const;
+
+function allOf(...permissions: PermissionId[]): SettingsPermissionPredicate {
+ return { allOf: permissions };
+}
+
+function anyOf(...predicates: SettingsPermissionPredicate[]): SettingsVisibility {
+ return { anyOf: predicates };
+}
+
+function lazyPanel(load: () => Promise): LazyExoticComponent {
+ return lazy(async () => ({ default: await load() }));
+}
+
+/** Settings categories and the permissions required for users to see them. */
export const SETTINGS_GROUPS = [
{
label: "Personal",
@@ -24,6 +60,10 @@ export const SETTINGS_GROUPS = [
description: "Theme and code highlighting",
keywords: "theme dark light syntax",
icon: AppearanceIcon,
+ visibility: publicSettings,
+ panel: lazyPanel(() =>
+ import("./appearance-settings").then(({ AppearanceSettings }) => AppearanceSettings)
+ ),
},
{
id: "keyboard-shortcuts",
@@ -31,6 +71,12 @@ export const SETTINGS_GROUPS = [
description: "Customize keyboard shortcuts",
keywords: "keys commands hotkeys",
icon: KeyboardIcon,
+ visibility: publicSettings,
+ panel: lazyPanel(() =>
+ import("./keyboard-shortcuts-settings").then(
+ ({ KeyboardShortcutsSettings }) => KeyboardShortcutsSettings
+ )
+ ),
},
],
},
@@ -43,6 +89,10 @@ export const SETTINGS_GROUPS = [
description: "Choose models available to agents",
keywords: "claude openai reasoning",
icon: ModelIcon,
+ visibility: anyOf("models.preferences.manage"),
+ panel: lazyPanel(() =>
+ import("./models-settings").then(({ ModelsSettings }) => ModelsSettings)
+ ),
},
{
id: "provider-accounts",
@@ -50,6 +100,12 @@ export const SETTINGS_GROUPS = [
description: "Connect model provider subscriptions",
keywords: "provider authentication credentials",
icon: KeyIcon,
+ visibility: anyOf("provider_accounts.read"),
+ panel: lazyPanel(() =>
+ import("./provider-accounts-settings").then(
+ ({ ProviderAccountsSettings }) => ProviderAccountsSettings
+ )
+ ),
},
{
id: "skills",
@@ -57,18 +113,37 @@ export const SETTINGS_GROUPS = [
description: "Manage shared skills and profiles",
keywords: "agent instructions profiles",
icon: SparkleIcon,
+ visibility: anyOf("skills.read"),
+ panel: lazyPanel(() =>
+ import("./skills-settings").then(({ SkillsSettings }) => SkillsSettings)
+ ),
},
],
},
{
label: "Workspace",
items: [
+ {
+ id: "workspace",
+ label: "Workspace access",
+ description: "Manage members and roles",
+ keywords: "rbac permissions users access",
+ icon: DataControlsIcon,
+ visibility: anyOf("workspace.members.read", "workspace.roles.read"),
+ panel: lazyPanel(() =>
+ import("./workspace-settings").then(({ WorkspaceSettings }) => WorkspaceSettings)
+ ),
+ },
{
id: "environments",
label: "Environments",
description: "Configure reusable repository setups",
keywords: "repositories branches prebuild",
icon: FolderIcon,
+ visibility: anyOf("environments.read"),
+ panel: lazyPanel(() =>
+ import("./environments-settings").then(({ EnvironmentsSettings }) => EnvironmentsSettings)
+ ),
},
{
id: "secrets",
@@ -76,6 +151,13 @@ export const SETTINGS_GROUPS = [
description: "Manage global and repository secrets",
keywords: "environment variables credentials",
icon: KeyIcon,
+ visibility: anyOf(
+ "global_secrets.manage",
+ allOf("repositories.secrets.manage", "repositories.read")
+ ),
+ panel: lazyPanel(() =>
+ import("./secrets-settings").then(({ SecretsSettings }) => SecretsSettings)
+ ),
},
{
id: "scm",
@@ -83,6 +165,10 @@ export const SETTINGS_GROUPS = [
description: "Configure pull request behavior",
keywords: "scm git pull request merge draft",
icon: GitPrIcon,
+ visibility: anyOf("integrations.read"),
+ panel: lazyPanel(() =>
+ import("./scm-settings").then(({ ScmSettingsPage }) => ScmSettingsPage)
+ ),
},
],
},
@@ -95,6 +181,10 @@ export const SETTINGS_GROUPS = [
description: "Set runtime resources and access",
keywords: "terminal ports cpu memory timeout",
icon: TerminalIcon,
+ visibility: anyOf("integrations.read"),
+ panel: lazyPanel(() =>
+ import("./sandbox-settings").then(({ SandboxSettingsPage }) => SandboxSettingsPage)
+ ),
},
{
id: "images",
@@ -103,6 +193,10 @@ export const SETTINGS_GROUPS = [
keywords: "prebuild containers",
icon: BoxIcon,
requiresRepoImages: true,
+ visibility: anyOf("image_builds.read"),
+ panel: lazyPanel(() =>
+ import("./images-settings").then(({ ImagesSettings }) => ImagesSettings)
+ ),
},
{
id: "integrations",
@@ -110,6 +204,10 @@ export const SETTINGS_GROUPS = [
description: "Connect external tools and services",
keywords: "github slack linear vnc code server",
icon: IntegrationsIcon,
+ visibility: anyOf("integrations.read"),
+ panel: lazyPanel(() =>
+ import("./integrations-settings").then(({ IntegrationsSettings }) => IntegrationsSettings)
+ ),
},
{
id: "mcp-servers",
@@ -117,6 +215,10 @@ export const SETTINGS_GROUPS = [
description: "Configure local and remote MCP servers",
keywords: "tools protocol command url",
icon: TerminalIcon,
+ visibility: anyOf("mcp_servers.read"),
+ panel: lazyPanel(() =>
+ import("./mcp-servers-settings").then(({ McpServersSettings }) => McpServersSettings)
+ ),
},
{
id: "data-controls",
@@ -124,43 +226,99 @@ export const SETTINGS_GROUPS = [
description: "Review and restore archived sessions",
keywords: "archive restore retention",
icon: DataControlsIcon,
+ visibility: anyOf("sessions.read"),
+ panel: lazyPanel(() =>
+ import("./data-controls-settings").then(
+ ({ DataControlsSettings }) => DataControlsSettings
+ )
+ ),
},
],
},
-] as const;
+] as const satisfies readonly SettingsGroupDefinition[];
type SettingsItem = (typeof SETTINGS_GROUPS)[number]["items"][number];
+/** Identifier for a registered settings category. */
export type SettingsCategory = SettingsItem["id"];
export const DEFAULT_SETTINGS_CATEGORY: SettingsCategory = "secrets";
export const DEFAULT_SETTINGS_QUERY = "";
+/** Returns whether the user's effective permissions make a settings category visible. */
+export function canViewSettingsCategory(
+ category: SettingsCategory,
+ hasPermission: (permission: PermissionId) => boolean
+): boolean {
+ const visibility = getSettingsItem(category).visibility;
+ return (
+ "public" in visibility ||
+ visibility.anyOf.some((predicate) =>
+ typeof predicate === "string"
+ ? hasPermission(predicate)
+ : predicate.allOf.every(hasPermission)
+ )
+ );
+}
+
+/** Selects the requested visible category, or a category the user is allowed to view. */
+export function resolveSettingsCategory(
+ requested: string | null,
+ repoImagesEnabled: boolean,
+ hasPermission: (permission: PermissionId) => boolean
+): SettingsCategory {
+ if (
+ isSettingsCategory(requested, repoImagesEnabled) &&
+ canViewSettingsCategory(requested, hasPermission)
+ ) {
+ return requested;
+ }
+ if (canViewSettingsCategory(DEFAULT_SETTINGS_CATEGORY, hasPermission)) {
+ return DEFAULT_SETTINGS_CATEGORY;
+ }
+ for (const group of SETTINGS_GROUPS) {
+ for (const item of group.items) {
+ if (
+ isSettingsItemAvailable(item, repoImagesEnabled) &&
+ canViewSettingsCategory(item.id, hasPermission)
+ ) {
+ return item.id;
+ }
+ }
+ }
+ return "appearance";
+}
+
function isSettingsItemAvailable(item: SettingsItem, repoImagesEnabled: boolean): boolean {
return !("requiresRepoImages" in item) || repoImagesEnabled;
}
+/** Returns settings groups filtered to categories the user may view and the current search. */
export function getSettingsGroups({
query = DEFAULT_SETTINGS_QUERY,
repoImagesEnabled = supportsRepoImages(),
+ hasPermission,
}: {
query?: string;
repoImagesEnabled?: boolean;
-} = {}) {
+ hasPermission: (permission: PermissionId) => boolean;
+}) {
return SETTINGS_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => {
if (!isSettingsItemAvailable(item, repoImagesEnabled)) return false;
+ if (!canViewSettingsCategory(item.id, hasPermission)) return false;
return matchesSearchTerms(`${item.label} ${item.description} ${item.keywords}`, query);
}),
})).filter((group) => group.items.length > 0);
}
+/** Returns the user-facing label for a settings category. */
export function getSettingsCategoryLabel(category: SettingsCategory): string {
- for (const group of SETTINGS_GROUPS) {
- for (const item of group.items) {
- if (item.id === category) return item.label;
- }
- }
- return category;
+ return getSettingsItem(category).label;
+}
+
+/** Returns the panel shown for an authorized settings category. */
+export function getSettingsPanel(category: SettingsCategory): LazyExoticComponent {
+ return getSettingsItem(category).panel;
}
export function isSettingsCategory(
@@ -174,3 +332,12 @@ export function isSettingsCategory(
)
);
}
+
+function getSettingsItem(category: SettingsCategory): SettingsItem {
+ for (const group of SETTINGS_GROUPS) {
+ for (const item of group.items) {
+ if (item.id === category) return item;
+ }
+ }
+ throw new Error(`Unknown settings category: ${category}`);
+}
diff --git a/packages/web/src/components/settings/settings-shell.test.tsx b/packages/web/src/components/settings/settings-shell.test.tsx
index efb8cbb8d..b4eecb1ac 100644
--- a/packages/web/src/components/settings/settings-shell.test.tsx
+++ b/packages/web/src/components/settings/settings-shell.test.tsx
@@ -6,10 +6,17 @@ import * as matchers from "@testing-library/jest-dom/matchers";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS_CATEGORY } from "./settings-nav";
import { SettingsShell } from "./settings-shell";
+import { PERMISSION_IDS } from "@open-inspect/shared/rbac";
expect.extend(matchers);
-const mocks = vi.hoisted(() => ({ isMobile: false, pathname: "", tab: "" }));
+const mocks = vi.hoisted(() => ({
+ isMobile: false,
+ pathname: "",
+ tab: "",
+ permissions: [] as string[],
+ replace: vi.fn(),
+}));
const SHELL_FIXTURE_DEFAULTS = {
isMobile: false,
pathname: "/settings",
@@ -18,15 +25,24 @@ const SHELL_FIXTURE_DEFAULTS = {
vi.mock("next/navigation", () => ({
usePathname: () => mocks.pathname,
- useRouter: () => ({ push: vi.fn() }),
+ useRouter: () => ({ push: vi.fn(), replace: mocks.replace }),
useSearchParams: () => new URLSearchParams(`tab=${mocks.tab}`),
}));
vi.mock("@/hooks/use-media-query", () => ({ useIsMobile: () => mocks.isMobile }));
vi.mock("@/lib/sandbox-provider", () => ({ supportsRepoImages: () => true }));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ authorization: { permissions: mocks.permissions },
+ loading: false,
+ hasPermission: (permission: string) => mocks.permissions.includes(permission),
+ }),
+}));
beforeEach(() => {
Object.assign(mocks, SHELL_FIXTURE_DEFAULTS);
+ mocks.permissions = [...PERMISSION_IDS];
+ mocks.replace.mockClear();
});
afterEach(() => {
@@ -57,4 +73,14 @@ describe("SettingsShell", () => {
expect(screen.queryByRole("navigation", { name: "Settings" })).not.toBeInTheDocument();
expect(screen.getByText("Mobile settings")).toBeInTheDocument();
});
+
+ it("redirects an unauthorized nested settings route", () => {
+ mocks.pathname = "/settings/integrations/github";
+ mocks.permissions = [];
+
+ render(Integration settings );
+
+ expect(mocks.replace).toHaveBeenCalledWith("/settings?tab=appearance");
+ expect(screen.queryByText("Integration settings")).not.toBeInTheDocument();
+ });
});
diff --git a/packages/web/src/components/settings/settings-shell.tsx b/packages/web/src/components/settings/settings-shell.tsx
index d00654d75..ef7d397f4 100644
--- a/packages/web/src/components/settings/settings-shell.tsx
+++ b/packages/web/src/components/settings/settings-shell.tsx
@@ -5,12 +5,13 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useIsMobile } from "@/hooks/use-media-query";
import { supportsRepoImages } from "@/lib/sandbox-provider";
import { SettingsViewportProvider } from "@/components/settings/settings-viewport-context";
-import {
- DEFAULT_SETTINGS_CATEGORY,
- isSettingsCategory,
- SettingsNav,
-} from "@/components/settings/settings-nav";
+import { SettingsNav } from "@/components/settings/settings-nav";
+import { resolveSettingsCategory } from "@/components/settings/settings-registry";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
+/**
+ * Hosts responsive settings content and redirects routes whose category is unavailable to the current user.
+ */
export function SettingsShell({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
@@ -18,15 +19,24 @@ export function SettingsShell({ children }: { children: React.ReactNode }) {
const isMobile = useIsMobile();
const [isHydrated, setIsHydrated] = useState(false);
const tab = searchParams.get("tab");
- const activeCategory = pathname.startsWith("/settings/integrations/")
- ? "integrations"
- : isSettingsCategory(tab, supportsRepoImages())
- ? tab
- : DEFAULT_SETTINGS_CATEGORY;
+ const { hasPermission, loading } = useCurrentUserAuthorization();
+ const requestedCategory = pathname.startsWith("/settings/integrations/") ? "integrations" : tab;
+ const activeCategory = resolveSettingsCategory(
+ requestedCategory,
+ supportsRepoImages(),
+ hasPermission
+ );
+ const unauthorizedSubroute =
+ pathname.startsWith("/settings/integrations/") && activeCategory !== "integrations";
useEffect(() => setIsHydrated(true), []);
+ useEffect(() => {
+ if (isHydrated && !loading && unauthorizedSubroute) {
+ router.replace(`/settings?tab=${activeCategory}`);
+ }
+ }, [activeCategory, isHydrated, loading, router, unauthorizedSubroute]);
- if (!isHydrated) {
+ if (!isHydrated || loading || unauthorizedSubroute) {
return ;
}
diff --git a/packages/web/src/components/settings/skills-settings/index.test.tsx b/packages/web/src/components/settings/skills-settings/index.test.tsx
new file mode 100644
index 000000000..f678f9406
--- /dev/null
+++ b/packages/web/src/components/settings/skills-settings/index.test.tsx
@@ -0,0 +1,59 @@
+// @vitest-environment jsdom
+///
+
+import { afterEach, expect, it, vi } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import * as matchers from "@testing-library/jest-dom/matchers";
+import { SkillsSettings } from "./index";
+
+expect.extend(matchers);
+
+const permissions = vi.hoisted(() => new Set());
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) => permissions.has(permission),
+ }),
+}));
+vi.mock("./skills-catalog", () => ({
+ SkillsCatalog: ({ canManage }: { canManage: boolean }) => (
+ Shared skills are {canManage ? "manageable" : "read-only"}
+ ),
+}));
+vi.mock("./profiles", () => ({
+ Profiles: ({ canManage }: { canManage: boolean }) => (
+ Personal profiles are {canManage ? "manageable" : "read-only"}
+ ),
+}));
+
+afterEach(() => {
+ cleanup();
+ permissions.clear();
+});
+
+it("keeps Viewer skills read-only and hides personal profiles", () => {
+ permissions.add("skills.read");
+
+ render( );
+
+ expect(screen.getByText("Shared skills are read-only")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "My profiles" })).not.toBeInTheDocument();
+});
+
+it("keeps both mutation surfaces available to a managing role", async () => {
+ permissions.add("skills.manage");
+ permissions.add("skill_profiles.manage_own");
+ const user = userEvent.setup();
+
+ const { rerender } = render( );
+
+ expect(screen.getByText("Shared skills are manageable")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "My profiles" }));
+ expect(screen.getByText("Personal profiles are manageable")).toBeInTheDocument();
+
+ permissions.delete("skill_profiles.manage_own");
+ rerender( );
+ expect(screen.queryByText("Personal profiles are manageable")).not.toBeInTheDocument();
+ expect(screen.getByText("Shared skills are manageable")).toBeInTheDocument();
+});
diff --git a/packages/web/src/components/settings/skills-settings/index.tsx b/packages/web/src/components/settings/skills-settings/index.tsx
index 07bd48519..df71c7bd4 100644
--- a/packages/web/src/components/settings/skills-settings/index.tsx
+++ b/packages/web/src/components/settings/skills-settings/index.tsx
@@ -3,11 +3,17 @@
import { useState } from "react";
import { Profiles } from "./profiles";
import { SkillsCatalog } from "./skills-catalog";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
type View = "skills" | "profiles";
+/**
+ * Switches between shared skills and personal profiles, granting each view its applicable management capability.
+ */
export function SkillsSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
const [view, setView] = useState("skills");
+ const canManageProfiles = hasPermission("skill_profiles.manage_own");
return (
@@ -16,19 +22,25 @@ export function SkillsSettings() {
Control the managed capabilities installed into new sessions.
-
- {(["skills", "profiles"] as const).map((item) => (
- setView(item)}
- className={`border-b-2 px-4 py-2 text-sm transition ${view === item ? "border-accent text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}
- >
- {item === "skills" ? "Shared skills" : "My profiles"}
-
- ))}
-
- {view === "skills" ?
:
}
+ {canManageProfiles && (
+
+ {(["skills", "profiles"] as const).map((item) => (
+ setView(item)}
+ className={`border-b-2 px-4 py-2 text-sm transition ${view === item ? "border-accent text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}
+ >
+ {item === "skills" ? "Shared skills" : "My profiles"}
+
+ ))}
+
+ )}
+ {view === "profiles" && canManageProfiles ? (
+
+ ) : (
+
+ )}
);
}
diff --git a/packages/web/src/components/settings/skills-settings/profiles.test.tsx b/packages/web/src/components/settings/skills-settings/profiles.test.tsx
index 851d708db..ef994be42 100644
--- a/packages/web/src/components/settings/skills-settings/profiles.test.tsx
+++ b/packages/web/src/components/settings/skills-settings/profiles.test.tsx
@@ -79,7 +79,7 @@ describe("Profiles", () => {
});
useSkillsMock.mockReturnValue({ skills: [], loading, error, mutate: vi.fn() });
- render( );
+ render( );
expect(screen.getByRole("button", { name: /Frontend work/ })).toHaveTextContent(message);
expect(screen.queryByText(/Unavailable/)).not.toBeInTheDocument();
diff --git a/packages/web/src/components/settings/skills-settings/profiles.tsx b/packages/web/src/components/settings/skills-settings/profiles.tsx
index 7dbf41705..1221c5631 100644
--- a/packages/web/src/components/settings/skills-settings/profiles.tsx
+++ b/packages/web/src/components/settings/skills-settings/profiles.tsx
@@ -136,7 +136,10 @@ export function ProfileForm({
);
}
-export function Profiles() {
+/**
+ * Lists the user's skill profiles and exposes profile mutations only when `canManage` is true.
+ */
+export function Profiles({ canManage }: { canManage: boolean }) {
const { profiles, loading, error, mutate } = useSkillProfiles();
const { skills, loading: skillsLoading, error: skillsError } = useSkills();
const [editing, setEditing] = useState(null);
@@ -172,9 +175,11 @@ export function Profiles() {
Save personal skill sets for session creation.
- setEditing("new")}>
- New profile
-
+ {canManage && (
+ setEditing("new")}>
+ New profile
+
+ )}
{error ? (
Failed to load skill profiles.
@@ -190,7 +195,8 @@ export function Profiles() {
setEditing(profile)}
+ onClick={() => canManage && setEditing(profile)}
+ disabled={!canManage}
className="min-w-0 flex-1 text-left"
>
{profile.name}
@@ -207,9 +213,11 @@ export function Profiles() {
.join(", ") || "None"}
-
remove(profile)}>
- Delete
-
+ {canManage && (
+
remove(profile)}>
+ Delete
+
+ )}
))}
diff --git a/packages/web/src/components/settings/skills-settings/skills-catalog.test.tsx b/packages/web/src/components/settings/skills-settings/skills-catalog.test.tsx
index 4bc44b43c..e40145c8c 100644
--- a/packages/web/src/components/settings/skills-settings/skills-catalog.test.tsx
+++ b/packages/web/src/components/settings/skills-settings/skills-catalog.test.tsx
@@ -70,7 +70,7 @@ afterEach(cleanup);
describe("SkillsCatalog", () => {
it("loads catalog pages on demand and navigates back with cursor history", async () => {
const user = userEvent.setup();
- render( );
+ render( );
expect(screen.getByText("first-skill")).toBeInTheDocument();
expect(screen.getByText("Page 1")).toBeInTheDocument();
@@ -105,7 +105,7 @@ describe("SkillsCatalog", () => {
error: undefined,
});
- render( );
+ render( );
expect(screen.getByText("· Created by User One")).toBeInTheDocument();
expect(screen.getByText("· Created by user-2")).toBeInTheDocument();
@@ -147,7 +147,7 @@ describe("SkillsCatalog", () => {
}
);
const user = userEvent.setup();
- render( );
+ render( );
await user.click(screen.getByRole("button", { name: "Next" }));
diff --git a/packages/web/src/components/settings/skills-settings/skills-catalog.tsx b/packages/web/src/components/settings/skills-settings/skills-catalog.tsx
index 714cc9882..bd5a24f3b 100644
--- a/packages/web/src/components/settings/skills-settings/skills-catalog.tsx
+++ b/packages/web/src/components/settings/skills-settings/skills-catalog.tsx
@@ -16,7 +16,10 @@ import { SkillEditor } from "./skill-editor";
import { SkillImport } from "./skill-import";
import { errorMessage } from "./utils";
-export function SkillsCatalog() {
+/**
+ * Displays the shared skill catalog and exposes catalog mutations only when `canManage` is true.
+ */
+export function SkillsCatalog({ canManage }: { canManage: boolean }) {
const [cursorHistory, setCursorHistory] = useState([]);
const cursor = cursorHistory.at(-1) ?? null;
const { skills, hasMore, nextCursor, loading, error } = useSkillCatalogPage(cursor);
@@ -113,14 +116,16 @@ export function SkillsCatalog() {
Manage reusable instructions assigned to repositories and environments.
-
-
setImporting(true)}>
- Import from repository
-
-
setCreating(true)}>
- New skill
-
-
+ {canManage && (
+
+
setImporting(true)}>
+ Import from repository
+
+
setCreating(true)}>
+ New skill
+
+
+ )}
{error ? (
Failed to load managed skills.
@@ -142,7 +147,8 @@ export function SkillsCatalog() {
setSelectedId(item.id)}
+ onClick={() => canManage && setSelectedId(item.id)}
+ disabled={!canManage}
className="min-w-0 flex-1 text-left"
>
@@ -171,14 +177,18 @@ export function SkillsCatalog() {
· Created by {item.creatorDisplayName || item.createdBy}
- toggleEnabled(item.id, value)}
- aria-label={`${item.enabled ? "Disable" : "Enable"} ${item.name}`}
- />
- remove(item.id, item.name)}>
- Delete
-
+ {canManage && (
+ toggleEnabled(item.id, value)}
+ aria-label={`${item.enabled ? "Disable" : "Enable"} ${item.name}`}
+ />
+ )}
+ {canManage && (
+ remove(item.id, item.name)}>
+ Delete
+
+ )}
))}
diff --git a/packages/web/src/components/settings/workspace-settings.test.tsx b/packages/web/src/components/settings/workspace-settings.test.tsx
new file mode 100644
index 000000000..d442a7712
--- /dev/null
+++ b/packages/web/src/components/settings/workspace-settings.test.tsx
@@ -0,0 +1,129 @@
+// @vitest-environment jsdom
+///
+
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import * as matchers from "@testing-library/jest-dom/matchers";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
+import { useWorkspaceAdministration } from "@/hooks/use-workspace-administration";
+import { WorkspaceSettings } from "./workspace-settings";
+
+expect.extend(matchers);
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: vi.fn(),
+}));
+vi.mock("@/hooks/use-workspace-administration", () => ({
+ useWorkspaceAdministration: vi.fn(),
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe("WorkspaceSettings", () => {
+ it("shows assigned role names to members-only readers", () => {
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: null,
+ loading: false,
+ error: null,
+ hasPermission: (permission) => permission === "workspace.members.read",
+ });
+ vi.mocked(useWorkspaceAdministration).mockReturnValue({
+ members: [
+ {
+ userId: "11111111111111111111111111111111",
+ displayName: "Ada",
+ email: "ada@example.com",
+ suspendedAt: null,
+ role: { id: "role_release", key: null, name: "Release Managers" },
+ },
+ ],
+ roles: [],
+ loading: false,
+ error: undefined,
+ updateMember: vi.fn(),
+ });
+
+ render( );
+
+ expect(screen.getByText("Release Managers")).toBeInTheDocument();
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ });
+
+ it("does not offer destructive controls for the sole unsuspended Owner", () => {
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: null,
+ loading: false,
+ error: null,
+ hasPermission: (permission) =>
+ permission === "workspace.members.read" ||
+ permission === "workspace.roles.read" ||
+ permission === "workspace.members.manage" ||
+ permission === "workspace.transfer_ownership",
+ });
+ vi.mocked(useWorkspaceAdministration).mockReturnValue({
+ members: [
+ {
+ userId: "11111111111111111111111111111111",
+ displayName: "Owner",
+ email: "owner@example.com",
+ suspendedAt: null,
+ role: { id: "role_builtin_owner", key: "owner", name: "Owner" },
+ },
+ ],
+ roles: [
+ {
+ id: "role_builtin_owner",
+ key: "owner",
+ name: "Owner",
+ description: null,
+ permissions: [],
+ assignmentCount: 1,
+ },
+ ],
+ loading: false,
+ error: undefined,
+ updateMember: vi.fn(),
+ });
+
+ render( );
+
+ expect(screen.getAllByText("Owner").length).toBeGreaterThan(0);
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Suspend" })).toBeDisabled();
+ });
+
+ it("restores a suspended member through the boolean status contract", async () => {
+ const updateMember = vi.fn().mockResolvedValue(undefined);
+ const member = {
+ userId: "11111111111111111111111111111111",
+ displayName: "Ada",
+ email: "ada@example.com",
+ suspendedAt: 100,
+ role: { id: "role_builtin_member", key: "member" as const, name: "Member" },
+ };
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: null,
+ loading: false,
+ error: null,
+ hasPermission: (permission) =>
+ permission === "workspace.members.read" || permission === "workspace.members.manage",
+ });
+ vi.mocked(useWorkspaceAdministration).mockReturnValue({
+ members: [member],
+ roles: [],
+ loading: false,
+ error: undefined,
+ updateMember,
+ });
+
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Restore" }));
+
+ await waitFor(() =>
+ expect(updateMember).toHaveBeenCalledWith(member, { kind: "status", suspended: false })
+ );
+ });
+});
diff --git a/packages/web/src/components/settings/workspace-settings.tsx b/packages/web/src/components/settings/workspace-settings.tsx
new file mode 100644
index 000000000..fab4d00a0
--- /dev/null
+++ b/packages/web/src/components/settings/workspace-settings.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import { useState } from "react";
+import { ErrorBanner } from "@/components/ui/error-banner";
+import { Button } from "@/components/ui/button";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
+import { useWorkspaceAdministration } from "@/hooks/use-workspace-administration";
+
+/**
+ * Shows workspace members and roles, exposing member controls only when the user may manage them.
+ */
+export function WorkspaceSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canReadMembers = hasPermission("workspace.members.read");
+ const canReadRoles = hasPermission("workspace.roles.read");
+ const canManage = hasPermission("workspace.members.manage");
+ const canAssignRoles = canManage && canReadRoles;
+ const canTransfer = hasPermission("workspace.transfer_ownership");
+ const { members, roles, loading, error, updateMember } = useWorkspaceAdministration({
+ readMembers: canReadMembers,
+ readRoles: canReadRoles,
+ });
+ const [mutationError, setMutationError] = useState(null);
+ const unsuspendedOwnerCount = members.filter(
+ (member) => member.role.key === "owner" && member.suspendedAt === null
+ ).length;
+
+ if (loading) return Loading workspace access...
;
+ if (error) return Failed to load workspace access. ;
+
+ async function mutate(action: () => Promise) {
+ setMutationError(null);
+ try {
+ await action();
+ } catch (cause) {
+ setMutationError(cause instanceof Error ? cause.message : "Workspace update failed");
+ }
+ }
+
+ return (
+
+
+
Workspace access
+
+ Assign one role to each canonical user. Backend authorization remains authoritative.
+
+
+
+ {mutationError &&
{mutationError} }
+
+ {canReadMembers && (
+
+
+ Members
+
+
+ {members.map((member) => (
+
+
+
+ {member.displayName ?? member.email ?? member.userId}
+
+
+ {member.email ?? member.userId}
+
+
+ {canAssignRoles &&
+ (member.role.key !== "owner" ||
+ (canTransfer &&
+ !(member.suspendedAt === null && unsuspendedOwnerCount === 1))) ? (
+
+ void mutate(() =>
+ updateMember(member, { kind: "role", roleId: event.target.value })
+ )
+ }
+ className="rounded border border-border bg-background px-2 py-1.5 text-sm"
+ >
+ {roles
+ .filter(
+ (role) => role.key !== "owner" || canTransfer || role.id === member.role.id
+ )
+ .map((role) => (
+
+ {role.name}
+
+ ))}
+
+ ) : (
+
{member.role.name}
+ )}
+
+ void mutate(() =>
+ updateMember(member, {
+ kind: "status",
+ suspended: member.suspendedAt === null,
+ })
+ )
+ }
+ >
+ {member.suspendedAt === null ? "Suspend" : "Restore"}
+
+
+ ))}
+
+
+ )}
+
+ {canReadRoles && (
+
+
+ Roles
+
+
+ {roles.map((role) => (
+
+
+
{role.name}
+
+ {role.assignmentCount} assigned
+
+
+
+ {role.description ?? `${role.permissions.length} permissions`}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/packages/web/src/hooks/use-automations.test.tsx b/packages/web/src/hooks/use-automations.test.tsx
index 5d1068117..c944029e6 100644
--- a/packages/web/src/hooks/use-automations.test.tsx
+++ b/packages/web/src/hooks/use-automations.test.tsx
@@ -25,6 +25,7 @@ function automation(id: string, name: string): AutomationListItem {
nextRunAt: null,
consecutiveFailures: 0,
createdBy: "user-1",
+ userId: "11111111111111111111111111111111",
createdAt: 1,
updatedAt: 1,
deletedAt: null,
diff --git a/packages/web/src/hooks/use-current-user-authorization.test.tsx b/packages/web/src/hooks/use-current-user-authorization.test.tsx
new file mode 100644
index 000000000..a8c087543
--- /dev/null
+++ b/packages/web/src/hooks/use-current-user-authorization.test.tsx
@@ -0,0 +1,60 @@
+// @vitest-environment jsdom
+
+import { renderHook, waitFor } from "@testing-library/react";
+import { SWRConfig } from "swr";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ReactNode } from "react";
+import { useAuthSession } from "@/lib/auth-session";
+import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useCurrentUserAuthorization } from "./use-current-user-authorization";
+
+vi.mock("@/lib/auth-session", () => ({ useAuthSession: vi.fn() }));
+vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() }));
+
+const authorizations = {
+ owner: {
+ userId: "11111111111111111111111111111111",
+ suspendedAt: null,
+ role: { id: "role_builtin_owner", key: "owner" as const, name: "Owner" },
+ permissions: ["workspace.transfer_ownership" as const],
+ },
+ member: {
+ userId: "22222222222222222222222222222222",
+ suspendedAt: null,
+ role: { id: "role_builtin_member", key: "member" as const, name: "Member" },
+ permissions: ["repositories.read" as const],
+ },
+};
+
+describe("useCurrentUserAuthorization", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("does not reuse cached authorization after the authenticated user changes", async () => {
+ let currentUser: keyof typeof authorizations = "owner";
+ vi.mocked(useAuthSession).mockImplementation(
+ () =>
+ ({
+ status: "authenticated",
+ data: { user: { id: authorizations[currentUser].userId } },
+ }) as ReturnType
+ );
+ vi.mocked(browserApiFetch).mockImplementation(async () =>
+ Response.json(authorizations[currentUser])
+ );
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ new Map(), dedupingInterval: 0 }}>{children}
+ );
+ const { result, rerender } = renderHook(useCurrentUserAuthorization, { wrapper });
+ await waitFor(() => expect(result.current.authorization?.role.key).toBe("owner"));
+ const ownerHasPermission = result.current.hasPermission;
+ rerender();
+ expect(result.current.hasPermission).toBe(ownerHasPermission);
+
+ currentUser = "member";
+ rerender();
+
+ await waitFor(() => expect(result.current.authorization?.role.key).toBe("member"));
+ expect(result.current.hasPermission).not.toBe(ownerHasPermission);
+ expect(result.current.hasPermission("workspace.transfer_ownership")).toBe(false);
+ });
+});
diff --git a/packages/web/src/hooks/use-current-user-authorization.ts b/packages/web/src/hooks/use-current-user-authorization.ts
new file mode 100644
index 000000000..8eb4204fe
--- /dev/null
+++ b/packages/web/src/hooks/use-current-user-authorization.ts
@@ -0,0 +1,53 @@
+"use client";
+
+import useSWR from "swr";
+import {
+ effectiveAuthorizationSchema,
+ type EffectiveAuthorization,
+ type PermissionId,
+} from "@open-inspect/shared/rbac";
+import { useAuthSession } from "@/lib/auth-session";
+import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useCallback } from "react";
+
+/** Endpoint key for the signed-in user's effective workspace authorization. */
+export const CURRENT_USER_AUTHORIZATION_KEY = "/api/me/authorization" as const;
+
+/** Returns the user-scoped cache key for effective workspace authorization. */
+export function currentUserAuthorizationKey(userId: string) {
+ return [CURRENT_USER_AUTHORIZATION_KEY, userId] as const;
+}
+
+async function fetchAuthorization(): Promise {
+ const response = await browserApiFetch(CURRENT_USER_AUTHORIZATION_KEY);
+ if (!response.ok) throw new Error(`Authorization request failed (${response.status})`);
+ return effectiveAuthorizationSchema.parse(await response.json());
+}
+
+/**
+ * Provides the signed-in user's effective permissions, denying permission checks until they load.
+ */
+export function useCurrentUserAuthorization(): {
+ authorization: EffectiveAuthorization | null;
+ loading: boolean;
+ error: unknown;
+ hasPermission: (permission: PermissionId) => boolean;
+} {
+ const { data: session, status } = useAuthSession();
+ const userId = session?.user?.id;
+ const { data, isLoading, error } = useSWR(
+ status === "authenticated" && userId ? currentUserAuthorizationKey(userId) : null,
+ fetchAuthorization
+ );
+ const hasPermission = useCallback(
+ (permission: PermissionId) => data?.permissions.includes(permission) ?? false,
+ [data?.permissions]
+ );
+
+ return {
+ authorization: data ?? null,
+ loading: status === "authenticated" && isLoading,
+ error,
+ hasPermission,
+ };
+}
diff --git a/packages/web/src/hooks/use-provider-accounts.test.tsx b/packages/web/src/hooks/use-provider-accounts.test.tsx
index 66e9b2c17..e31b3dbba 100644
--- a/packages/web/src/hooks/use-provider-accounts.test.tsx
+++ b/packages/web/src/hooks/use-provider-accounts.test.tsx
@@ -18,12 +18,21 @@ import {
useProviderAccounts,
} from "./use-provider-accounts";
-vi.mock("@/lib/auth-session", () => ({
- useAuthSession: () => ({ data: { user: { id: "user-1" } }, status: "authenticated" }),
+const permissions = vi.hoisted(() => new Set());
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) => permissions.has(permission),
+ }),
}));
vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() }));
+beforeEach(() => {
+ permissions.clear();
+ permissions.add("provider_accounts.read");
+});
+
function wrapper({ children }: { children: ReactNode }) {
return (
new Map(), dedupingInterval: 0 }}>{children}
@@ -91,6 +100,34 @@ describe("useLegacyProviderCredentials", () => {
describe("useProviderAccounts", () => {
beforeEach(() => vi.clearAllMocks());
+ it("does not request provider resources without read permission", () => {
+ permissions.clear();
+
+ const { result } = renderHook(
+ () => ({ accounts: useProviderAccounts(), legacy: useLegacyProviderCredentials() }),
+ { wrapper }
+ );
+
+ expect(browserApiFetch).not.toHaveBeenCalled();
+ expect(result.current.accounts).toMatchObject({ accounts: [], defaults: [], loading: false });
+ expect(result.current.legacy).toMatchObject({ legacyKeys: [], loading: false });
+ });
+
+ it("clears provider resources when read permission is revoked", async () => {
+ vi.mocked(browserApiFetch)
+ .mockResolvedValueOnce(Response.json({ accounts: [account] }))
+ .mockResolvedValueOnce(Response.json({ defaults: [] }));
+
+ const { result, rerender } = renderHook(() => useProviderAccounts(), { wrapper });
+ await waitFor(() => expect(result.current.accounts).toEqual([account]));
+
+ permissions.clear();
+ rerender();
+
+ expect(result.current).toMatchObject({ accounts: [], defaults: [], loading: false });
+ expect(browserApiFetch).toHaveBeenCalledTimes(2);
+ });
+
it("uses the shared static provider catalog without fetching it", async () => {
vi.mocked(browserApiFetch)
.mockResolvedValueOnce(Response.json({ accounts: [] }))
diff --git a/packages/web/src/hooks/use-provider-accounts.ts b/packages/web/src/hooks/use-provider-accounts.ts
index 5c2a2348c..b281221f9 100644
--- a/packages/web/src/hooks/use-provider-accounts.ts
+++ b/packages/web/src/hooks/use-provider-accounts.ts
@@ -1,6 +1,6 @@
import useSWR from "swr";
import { z, type ZodType } from "zod";
-import { useAuthSession } from "@/lib/auth-session";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch";
import {
modelProviderAccountDefaultsResponseSchema,
@@ -89,11 +89,12 @@ async function requestProviderResourceWithoutContent(
}
export function useProviderAccounts() {
- const { data: session } = useAuthSession();
- const accounts = useSWR(session ? ACCOUNTS_KEY : null, async (path) => {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canRead = hasPermission("provider_accounts.read");
+ const accounts = useSWR(canRead ? ACCOUNTS_KEY : null, async (path) => {
return (await requestProviderResource(path, modelProviderAccountsResponseSchema)).accounts;
});
- const defaults = useSWR(session ? DEFAULTS_KEY : null, async (path) => {
+ const defaults = useSWR(canRead ? DEFAULTS_KEY : null, async (path) => {
return (await requestProviderResource(path, modelProviderAccountDefaultsResponseSchema))
.defaults;
});
@@ -112,9 +113,10 @@ export function useProviderAccounts() {
}
export function useLegacyProviderCredentials() {
- const { data: session } = useAuthSession();
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canRead = hasPermission("provider_accounts.read");
const result = useSWR(
- session ? LEGACY_CREDENTIALS_KEY : null,
+ canRead ? LEGACY_CREDENTIALS_KEY : null,
async (path: BrowserApiPath) => {
return requestProviderResource(path, legacyProviderCredentialsResponseSchema);
}
diff --git a/packages/web/src/hooks/use-repos.test.tsx b/packages/web/src/hooks/use-repos.test.tsx
new file mode 100644
index 000000000..2aaeefa80
--- /dev/null
+++ b/packages/web/src/hooks/use-repos.test.tsx
@@ -0,0 +1,31 @@
+// @vitest-environment jsdom
+
+import { renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useRepos } from "./use-repos";
+
+const mocks = vi.hoisted(() => ({ useSWR: vi.fn() }));
+
+vi.mock("swr", () => ({ default: mocks.useSWR }));
+vi.mock("@/lib/auth-session", () => ({
+ useAuthSession: () => ({ data: { user: {} }, status: "authenticated" }),
+}));
+
+describe("useRepos", () => {
+ beforeEach(() => {
+ mocks.useSWR.mockReset();
+ mocks.useSWR.mockReturnValue({ data: undefined, isLoading: false, error: undefined });
+ });
+
+ it("does not request repositories when the caller is unauthorized", () => {
+ renderHook(() => useRepos(false));
+
+ expect(mocks.useSWR).toHaveBeenCalledWith(null);
+ });
+
+ it("requests repositories when enabled", () => {
+ renderHook(() => useRepos());
+
+ expect(mocks.useSWR).toHaveBeenCalledWith("/api/repos");
+ });
+});
diff --git a/packages/web/src/hooks/use-repos.ts b/packages/web/src/hooks/use-repos.ts
index f4f3799f6..56f3a3a7b 100644
--- a/packages/web/src/hooks/use-repos.ts
+++ b/packages/web/src/hooks/use-repos.ts
@@ -15,16 +15,21 @@ interface ReposResponse {
repos: Repo[];
}
-export function useRepos() {
+/**
+ * Loads repositories for an authenticated user when enabled, allowing callers to suppress unauthorized requests.
+ */
+export function useRepos(enabled = true) {
const { data: session, status } = useAuthSession();
- const { data, isLoading, error } = useSWR(session ? "/api/repos" : null);
+ const { data, isLoading, error } = useSWR(
+ enabled && session ? "/api/repos" : null
+ );
return {
repos: data?.repos ?? [],
// The fetch is gated on the auth session, so the list is still loading
// while the session itself resolves — don't report an authoritative [].
- loading: status === "loading" || isLoading,
+ loading: enabled && (status === "loading" || isLoading),
error,
};
}
diff --git a/packages/web/src/hooks/use-workspace-administration.test.tsx b/packages/web/src/hooks/use-workspace-administration.test.tsx
new file mode 100644
index 000000000..44046d1dd
--- /dev/null
+++ b/packages/web/src/hooks/use-workspace-administration.test.tsx
@@ -0,0 +1,55 @@
+// @vitest-environment jsdom
+
+import { act, renderHook } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { SWRConfig } from "swr";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useAuthSession } from "@/lib/auth-session";
+import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useWorkspaceAdministration } from "./use-workspace-administration";
+
+vi.mock("@/lib/auth-session", () => ({ useAuthSession: vi.fn() }));
+vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() }));
+
+const wrapper = ({ children }: { children: ReactNode }) => (
+ new Map(), dedupingInterval: 0 }}>{children}
+);
+
+const member = {
+ userId: "11111111111111111111111111111111",
+ displayName: "Ada",
+ email: "ada@example.com",
+ avatarUrl: null,
+ suspendedAt: null,
+ role: { id: "role_builtin_member", key: "member" as const, name: "Member" },
+ createdAt: 1,
+};
+
+describe("useWorkspaceAdministration", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(useAuthSession).mockReturnValue({ data: null, status: "unauthenticated" });
+ vi.mocked(browserApiFetch).mockResolvedValue(new Response(null, { status: 204 }));
+ });
+
+ it("sends the simplified role and suspension mutation contracts", async () => {
+ const { result } = renderHook(
+ () => useWorkspaceAdministration({ readMembers: false, readRoles: false }),
+ { wrapper }
+ );
+
+ await act(() => result.current.updateMember(member, { kind: "role", roleId: "role_release" }));
+ await act(() => result.current.updateMember(member, { kind: "status", suspended: true }));
+
+ expect(browserApiFetch).toHaveBeenNthCalledWith(
+ 1,
+ `/api/members/${member.userId}/role`,
+ expect.objectContaining({ method: "PUT", body: JSON.stringify({ roleId: "role_release" }) })
+ );
+ expect(browserApiFetch).toHaveBeenNthCalledWith(
+ 2,
+ `/api/members/${member.userId}/status`,
+ expect.objectContaining({ method: "PUT", body: JSON.stringify({ suspended: true }) })
+ );
+ });
+});
diff --git a/packages/web/src/hooks/use-workspace-administration.ts b/packages/web/src/hooks/use-workspace-administration.ts
new file mode 100644
index 000000000..6fc155cbd
--- /dev/null
+++ b/packages/web/src/hooks/use-workspace-administration.ts
@@ -0,0 +1,67 @@
+"use client";
+
+import useSWR, { useSWRConfig } from "swr";
+import {
+ roleListResponseSchema,
+ workspaceMemberListResponseSchema,
+ type RoleSummary,
+ type WorkspaceMember,
+} from "@open-inspect/shared/rbac";
+import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useAuthSession } from "@/lib/auth-session";
+import { currentUserAuthorizationKey } from "./use-current-user-authorization";
+
+async function fetchMembers(): Promise {
+ const response = await browserApiFetch("/api/members");
+ if (!response.ok) throw new Error(`Members request failed (${response.status})`);
+ return workspaceMemberListResponseSchema.parse(await response.json());
+}
+
+async function fetchRoles(): Promise {
+ const response = await browserApiFetch("/api/roles");
+ if (!response.ok) throw new Error(`Roles request failed (${response.status})`);
+ return roleListResponseSchema.parse(await response.json());
+}
+
+/**
+ * Provides the workspace members and roles the current user may read, plus authorized member updates.
+ */
+export function useWorkspaceAdministration(input: { readMembers: boolean; readRoles: boolean }) {
+ const { mutate } = useSWRConfig();
+ const { data: session } = useAuthSession();
+ const members = useSWR(input.readMembers ? "/api/members" : null, fetchMembers);
+ const roles = useSWR(input.readRoles ? "/api/roles" : null, fetchRoles);
+
+ async function updateMember(
+ user: WorkspaceMember,
+ action: { kind: "role"; roleId: string } | { kind: "status"; suspended: boolean }
+ ): Promise {
+ const path =
+ action.kind === "role"
+ ? (`/api/members/${encodeURIComponent(user.userId)}/role` as const)
+ : (`/api/members/${encodeURIComponent(user.userId)}/status` as const);
+ const body =
+ action.kind === "role" ? { roleId: action.roleId } : { suspended: action.suspended };
+ const response = await browserApiFetch(path, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) throw new Error(`Member update failed (${response.status})`);
+ await Promise.all([
+ members.mutate(),
+ roles.mutate(),
+ session?.user?.id
+ ? mutate(currentUserAuthorizationKey(session.user.id), undefined, { revalidate: true })
+ : Promise.resolve(undefined),
+ ]);
+ }
+
+ return {
+ members: members.data ?? [],
+ roles: roles.data ?? [],
+ loading: (input.readMembers && members.isLoading) || (input.readRoles && roles.isLoading),
+ error: members.error ?? roles.error,
+ updateMember,
+ };
+}
From 4866d41a315772efbaf9f503104daf18738a5506 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Sun, 30 Aug 2026 22:34:24 -0700
Subject: [PATCH 6/9] fix(rbac): address foundation review feedback
---
.github/workflows/ci.yml | 4 +-
package-lock.json | 2 +-
package.json | 2 +-
.../src/authorization/service.ts | 5 +-
.../src/db/authorization-store.test.ts | 29 ++--
.../src/db/authorization-store.ts | 80 ++++++++---
packages/control-plane/src/db/user-merge.ts | 133 +++++++++++++----
.../test/integration/rbac-foundation.test.ts | 118 ++++++++++++++-
.../test/integration/user-merge.test.ts | 134 ++++++++++++++++++
packages/shared/src/rbac.test.ts | 20 +++
packages/shared/src/rbac.ts | 57 ++++++--
scripts/bootstrap-workspace-owner.test.ts | 68 ++++++++-
scripts/bootstrap-workspace-owner.ts | 38 +++--
scripts/merge-split-users.ts | 55 +++----
.../d1/migrations/0071_rbac_foundation.sql | 14 +-
15 files changed, 644 insertions(+), 115 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 712ab6035..f70ac230d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -65,7 +65,9 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
- node-version: "22"
+ # Minimum supported release: node:sqlite is available without an
+ # additional flag and --experimental-transform-types is present.
+ node-version: "22.13.0"
cache: "npm"
- name: Install dependencies
diff --git a/package-lock.json b/package-lock.json
index 329515bac..3c0cb3a55 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,7 +27,7 @@
"wrangler": "^4.103.0"
},
"engines": {
- "node": ">=22.0.0"
+ "node": ">=22.13.0"
}
},
"node_modules/@acemir/cssom": {
diff --git a/package.json b/package.json
index 35871e3ed..5c7a63432 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,7 @@
"wrangler": "^4.103.0"
},
"engines": {
- "node": ">=22.0.0"
+ "node": ">=22.13.0"
},
"overrides": {
"minimatch": "^10.2.5",
diff --git a/packages/control-plane/src/authorization/service.ts b/packages/control-plane/src/authorization/service.ts
index 36da1c984..bab14ebae 100644
--- a/packages/control-plane/src/authorization/service.ts
+++ b/packages/control-plane/src/authorization/service.ts
@@ -156,9 +156,12 @@ export class AuthorizationService {
if (outcome.status === "actor_authorization_changed") {
throw new RbacConflictError("Actor authorization changed");
}
- if (outcome.status === "not_found") {
+ if (outcome.status === "role_not_found") {
throw new AuthorizationError(404, "role_not_found");
}
+ if (outcome.status === "member_not_found") {
+ throw new AuthorizationError(404, "member_not_found");
+ }
if (outcome.status === "conflict") {
throw new RbacConflictError(conflictMessage);
}
diff --git a/packages/control-plane/src/db/authorization-store.test.ts b/packages/control-plane/src/db/authorization-store.test.ts
index 780045503..8830ccf91 100644
--- a/packages/control-plane/src/db/authorization-store.test.ts
+++ b/packages/control-plane/src/db/authorization-store.test.ts
@@ -62,20 +62,23 @@ describe("AuthorizationStore", () => {
]);
});
- it.each(["applied", "actor_authorization_changed", "not_found", "conflict"] as const)(
- "returns the %s member status replacement batch outcome",
- async (status) => {
- const store = new AuthorizationStore(
- fakeDatabase({
- batchResults: [result(0, [{ status }]), result(1), result(1), result(1)],
- })
- );
+ it.each([
+ "applied",
+ "actor_authorization_changed",
+ "role_not_found",
+ "member_not_found",
+ "conflict",
+ ] as const)("returns the %s member status replacement batch outcome", async (status) => {
+ const store = new AuthorizationStore(
+ fakeDatabase({
+ batchResults: [result(0, [{ status }]), result(1), result(1), result(1)],
+ })
+ );
- await expect(store.replaceMemberStatus(replaceMemberStatusInput)).resolves.toEqual({
- status,
- });
- }
- );
+ await expect(store.replaceMemberStatus(replaceMemberStatusInput)).resolves.toEqual({
+ status,
+ });
+ });
it("does not classify an unexpected database failure as a conflict", async () => {
const failure = new Error("database unavailable");
diff --git a/packages/control-plane/src/db/authorization-store.ts b/packages/control-plane/src/db/authorization-store.ts
index f03b54605..1bd249121 100644
--- a/packages/control-plane/src/db/authorization-store.ts
+++ b/packages/control-plane/src/db/authorization-store.ts
@@ -1,7 +1,9 @@
import {
BUILT_IN_ROLE_REGISTRY,
+ roleReferenceSchema,
type BuiltInRoleKey,
type PermissionId,
+ type RoleReference,
type WorkspaceMember,
} from "@open-inspect/shared/rbac";
import { rolePermissionPredicate } from "../authorization/permission-sql";
@@ -39,17 +41,14 @@ interface MemberRow {
export interface EffectiveAuthorizationRecord {
userId: string;
suspendedAt: number | null;
- role: { id: string; key: BuiltInRoleKey | null; name: string } | null;
+ role: RoleReference | null;
}
/** Persistence view of a role and the number of users currently assigned to it. */
-export interface AuthorizationRoleRecord {
- id: string;
- key: BuiltInRoleKey | null;
- name: string;
+export type AuthorizationRoleRecord = RoleReference & {
description: string | null;
assignmentCount: number;
-}
+};
interface AuditInput {
requestId: string;
@@ -93,25 +92,33 @@ function anotherUnsuspendedOwner(targetUserId: string): SqlCondition {
export type AuthorizationMutationOutcome =
| { status: "applied" }
| { status: "actor_authorization_changed" }
- | { status: "not_found" }
+ | { status: "role_not_found" }
+ | { status: "member_not_found" }
| { status: "conflict" };
+type NotFoundStatus = Extract<
+ AuthorizationMutationOutcome["status"],
+ "role_not_found" | "member_not_found"
+>;
+
+function toRoleReference(id: string, key: BuiltInRoleKey | null, name: string): RoleReference {
+ return roleReferenceSchema.parse({ id, key, name });
+}
+
function toEffectiveAuthorizationRecord(row: EffectiveRow): EffectiveAuthorizationRecord {
return {
userId: row.user_id,
suspendedAt: row.suspended_at,
role:
row.role_id && row.role_name
- ? { id: row.role_id, key: row.role_key, name: row.role_name }
+ ? toRoleReference(row.role_id, row.role_key, row.role_name)
: null,
};
}
function toRoleRecord(row: RoleRow): AuthorizationRoleRecord {
return {
- id: row.id,
- key: row.key,
- name: row.name,
+ ...toRoleReference(row.id, row.key, row.name),
description: row.description,
assignmentCount: Number(row.assignment_count),
};
@@ -123,7 +130,7 @@ function toMember(row: MemberRow): WorkspaceMember {
displayName: row.display_name,
email: row.email,
suspendedAt: row.suspended_at,
- role: { id: row.role_id, key: row.role_key, name: row.role_name },
+ role: toRoleReference(row.role_id, row.role_key, row.role_name),
};
}
@@ -240,6 +247,22 @@ export class AuthorizationStore {
sql: `(? <> ? AND NOT (${targetIsOwner.sql})) OR ${transferGuard.sql}`,
values: [input.roleId, OWNER_ROLE_ID, ...targetIsOwner.values, ...transferGuard.values],
},
+ notFound: [
+ {
+ status: "role_not_found",
+ condition: {
+ sql: "NOT EXISTS (SELECT 1 FROM roles WHERE id = ?)",
+ values: [input.roleId],
+ },
+ },
+ {
+ status: "member_not_found",
+ condition: {
+ sql: "NOT EXISTS (SELECT 1 FROM user_role_assignments WHERE user_id = ?)",
+ values: [input.targetUserId],
+ },
+ },
+ ],
}
);
const results = await this.db.batch([
@@ -308,6 +331,19 @@ export class AuthorizationStore {
sql: `NOT (${targetIsOwner.sql}) OR ${transferGuard.sql}`,
values: [...targetIsOwner.values, ...transferGuard.values],
},
+ notFound: [
+ {
+ status: "member_not_found",
+ condition: {
+ sql: `NOT EXISTS (
+ SELECT 1 FROM users
+ JOIN user_role_assignments ON user_role_assignments.user_id = users.id
+ WHERE users.id = ?
+ )`,
+ values: [input.targetUserId],
+ },
+ },
+ ],
}
);
const statements: SqlStatement[] = [
@@ -355,7 +391,10 @@ export class AuthorizationStore {
actorUserId: string,
permissions: PermissionId[],
resourceCondition: SqlCondition,
- options?: { actor?: SqlCondition; notFound?: SqlCondition }
+ options?: {
+ actor?: SqlCondition;
+ notFound?: Array<{ status: NotFoundStatus; condition: SqlCondition }>;
+ }
): {
outcome: SqlStatement;
applied: SqlCondition;
@@ -383,17 +422,25 @@ export class AuthorizationStore {
values: [...actor.values, ...resourceCondition.values],
};
const auditId = crypto.randomUUID();
+ const notFoundCases =
+ options?.notFound
+ ?.map(({ status, condition }) => `WHEN (${condition.sql}) THEN '${status}'`)
+ .join("\n ") ?? "";
return {
outcome: this.db
.prepare(
`SELECT CASE
WHEN NOT (${actor.sql}) THEN 'actor_authorization_changed'
- ${options?.notFound ? `WHEN (${options.notFound.sql}) THEN 'not_found'` : ""}
+ ${notFoundCases}
WHEN NOT (${resourceCondition.sql}) THEN 'conflict'
ELSE 'applied'
END AS status`
)
- .bind(...actor.values, ...(options?.notFound?.values ?? []), ...resourceCondition.values),
+ .bind(
+ ...actor.values,
+ ...(options?.notFound?.flatMap(({ condition }) => condition.values) ?? []),
+ ...resourceCondition.values
+ ),
applied,
writes: {
sql: "EXISTS (SELECT 1 FROM authorization_audit_events WHERE id = ?)",
@@ -408,7 +455,8 @@ export class AuthorizationStore {
if (
status !== "applied" &&
status !== "actor_authorization_changed" &&
- status !== "not_found" &&
+ status !== "role_not_found" &&
+ status !== "member_not_found" &&
status !== "conflict"
) {
throw new Error("Invalid authorization mutation outcome");
diff --git a/packages/control-plane/src/db/user-merge.ts b/packages/control-plane/src/db/user-merge.ts
index e098a2815..95e3d190c 100644
--- a/packages/control-plane/src/db/user-merge.ts
+++ b/packages/control-plane/src/db/user-merge.ts
@@ -20,12 +20,9 @@ import type { SqlDatabase, SqlResult, SqlStatement } from "./sql-database";
* `idx_user_identities_provider`).
* - `automations.created_by` is re-pointed value-conditionally: legacy rows
* store GitHub numeric ids, which must never be rewritten.
- * - Idempotent: re-running a completed merge is a zero-count no-op, and a
- * partially-applied run is repaired by running the script again — with one
- * exception: the final email backfill's input (the loser row) is deleted by
- * the preceding statement, so a stop exactly between those two statements
- * is not re-derivable from the database. The CLI prints a recovery record
- * before executing to cover that residual case.
+ * - Idempotent: re-running a completed merge is a zero-count no-op. The
+ * execute path requires an atomic SqlDatabase batch so no partial graph can
+ * become externally visible.
* - Browser sessions (`auth_sessions`) issued to the loser are deleted. An
* issued bearer credential is never rewritten to authenticate as another
* canonical user.
@@ -74,6 +71,15 @@ const USER_MERGE_COUNT_KEYS = [
"roleAssignmentsRemoved",
"providerAccountAuthorizationsRepointed",
"providerAccountAuthorizationAttemptsRepointed",
+ "providerAccountsCreatedRepointed",
+ "providerAccountsUpdatedRepointed",
+ "providerAccountDefaultsCreatedRepointed",
+ "providerAccountDefaultsUpdatedRepointed",
+ "skillsCreatedRepointed",
+ "skillsUpdatedRepointed",
+ "skillRevisionsCreatedRepointed",
+ "skillAssignmentsCreatedRepointed",
+ "skillCatalogGenerationsAdvanced",
"keyboardShortcutPreferencesDeduped",
"keyboardShortcutPreferencesRepointed",
"auditEventsCreated",
@@ -84,6 +90,12 @@ const USER_MERGE_COUNT_KEYS = [
type UserMergeCountKey = (typeof USER_MERGE_COUNT_KEYS)[number];
type UserMergeCounts = Record;
+const RESULT_CHANGE_DIVISORS: Partial> = {
+ // The assignment UPDATE trigger also advances skills_catalog_state once per
+ // changed assignment, and D1 includes both rows in meta.changes.
+ skillAssignmentsCreatedRepointed: 2,
+};
+
interface MergeOperation {
readonly key: UserMergeCountKey;
readonly execute: (db: SqlDatabase, survivorId: string, loserId: string) => SqlStatement;
@@ -178,12 +190,48 @@ const SKILL_PROFILE_OPERATIONS = dedupeThenRepoint({
)`,
});
+const SKILL_CATALOG_GENERATION_OPERATION: MergeOperation = {
+ key: "skillCatalogGenerationsAdvanced",
+ execute: (db, _survivorId, loserId) =>
+ db
+ .prepare(
+ `UPDATE skills_catalog_state SET generation = generation + 1
+ WHERE singleton = 1
+ AND EXISTS (SELECT 1 FROM skill_profiles WHERE user_id = ?)`
+ )
+ .bind(loserId),
+ preview: (db, _survivorId, loserId) =>
+ db
+ .prepare(
+ `SELECT COUNT(*) AS count FROM skills_catalog_state
+ WHERE singleton = 1
+ AND EXISTS (SELECT 1 FROM skill_profiles WHERE user_id = ?)`
+ )
+ .bind(loserId),
+};
+
const FINAL_REPOINT_OPERATIONS = [
regularRepoint("providerAccountAuthorizationsRepointed", "model_provider_account_authorizations"),
regularRepoint(
"providerAccountAuthorizationAttemptsRepointed",
"model_provider_account_authorization_attempts"
),
+ regularRepoint("providerAccountsCreatedRepointed", "model_provider_accounts", "created_by"),
+ regularRepoint("providerAccountsUpdatedRepointed", "model_provider_accounts", "updated_by"),
+ regularRepoint(
+ "providerAccountDefaultsCreatedRepointed",
+ "model_provider_account_defaults",
+ "created_by"
+ ),
+ regularRepoint(
+ "providerAccountDefaultsUpdatedRepointed",
+ "model_provider_account_defaults",
+ "updated_by"
+ ),
+ regularRepoint("skillsCreatedRepointed", "skills", "created_by"),
+ regularRepoint("skillsUpdatedRepointed", "skills", "updated_by"),
+ regularRepoint("skillRevisionsCreatedRepointed", "skill_revisions", "created_by"),
+ regularRepoint("skillAssignmentsCreatedRepointed", "skill_assignments", "created_by"),
...dedupeThenRepoint({
dedupeKey: "keyboardShortcutPreferencesDeduped",
repointKey: "keyboardShortcutPreferencesRepointed",
@@ -194,6 +242,7 @@ const FINAL_REPOINT_OPERATIONS = [
const TABLE_OPERATIONS = [
...BEFORE_SKILL_PROFILE_OPERATIONS,
+ SKILL_CATALOG_GENERATION_OPERATION,
...SKILL_PROFILE_OPERATIONS,
...FINAL_REPOINT_OPERATIONS,
] as const;
@@ -229,14 +278,15 @@ export async function mergeUsers(
throw new UserMergeError(`Survivor user ${survivorId} not found`);
}
// A missing loser row is not an error: re-running a completed merge must
- // be a no-op, and a partially-applied merge must be resumable.
+ // be a no-op after an already-completed atomic merge.
const loser = await db
- .prepare(`SELECT id, email, email_verified FROM users WHERE id = ?`)
+ .prepare(`SELECT id, email, email_verified, suspended_at FROM users WHERE id = ?`)
.bind(loserId)
.first<{
id: string;
email: string | null;
email_verified: number;
+ suspended_at: number | null;
}>();
if (!loser) {
return { survivorId, loserId, dryRun: options.dryRun === true, counts: emptyCounts() };
@@ -269,6 +319,9 @@ export async function mergeUsers(
if (survivorRole && loserRole && survivorRole.role_id !== loserRole.role_id) {
throw new UserMergeError("Resolve conflicting user roles before merging");
}
+ if (survivor.suspended_at !== loser.suspended_at) {
+ throw new UserMergeError("Resolve conflicting user suspension states before merging");
+ }
if (loserRole?.role_key === "owner" && survivor.suspended_at !== null) {
throw new UserMergeError("The surviving Owner must be active before merging");
}
@@ -298,11 +351,54 @@ export async function mergeUsers(
}
};
+ const auditId = crypto.randomUUID();
+ const occurredAt = Date.now();
+ // The NOT NULL occurred_at column turns a failed revalidation into a batch
+ // error, rolling back every merge write. This closes the preflight/write
+ // window for role, suspension, and last-active-Owner invariants.
+ add(
+ "auditEventsCreated",
+ db
+ .prepare(
+ `INSERT INTO authorization_audit_events
+ (id, occurred_at, request_id, principal_kind,
+ actor_service_snapshot, action, resource_type, resource_id,
+ target_user_id_snapshot, reason_code)
+ VALUES (
+ ?,
+ CASE WHEN EXISTS (
+ SELECT 1
+ FROM users survivor
+ JOIN user_role_assignments survivor_assignment
+ ON survivor_assignment.user_id = survivor.id
+ JOIN users loser ON loser.id = ?
+ JOIN user_role_assignments loser_assignment
+ ON loser_assignment.user_id = loser.id
+ JOIN roles role ON role.id = loser_assignment.role_id
+ WHERE survivor.id = ?
+ AND survivor_assignment.role_id = loser_assignment.role_id
+ AND survivor.suspended_at IS loser.suspended_at
+ AND (role.key IS NULL OR role.key <> 'owner' OR survivor.suspended_at IS NULL)
+ ) THEN ? ELSE NULL END,
+ 'user-merge', 'service', 'control-plane',
+ 'workspace.user_merged', 'user', ?, ?, 'operator_merge'
+ )`
+ )
+ .bind(auditId, loserId, survivorId, occurredAt, survivorId, loserId)
+ );
+
// Dedup before re-pointing: drop loser rows whose target slot the survivor
// already occupies (identities under idx_user_identities_provider; read
// states routinely, where both split rows read the same session).
addOperations(BEFORE_SKILL_PROFILE_OPERATIONS);
+ // Profile resolution uses this generation as a consistency fence. Advance
+ // it before any profile membership or ownership rows are changed.
+ add(
+ SKILL_CATALOG_GENERATION_OPERATION.key,
+ SKILL_CATALOG_GENERATION_OPERATION.execute(db, survivorId, loserId)
+ );
+
// Merge items before deleting colliding skill profiles.
add(
"skillProfileItemsMerged",
@@ -327,21 +423,6 @@ export async function mergeUsers(
);
addOperations(FINAL_REPOINT_OPERATIONS);
- // Record the merge before deleting the user so the snapshots remain explicit.
- add(
- "auditEventsCreated",
- db
- .prepare(
- `INSERT INTO authorization_audit_events
- (id, occurred_at, request_id, principal_kind,
- actor_service_snapshot, action, resource_type, resource_id,
- target_user_id_snapshot, reason_code)
- VALUES (?, ?, 'user-merge', 'service', 'control-plane',
- 'workspace.user_merged', 'user', ?, ?, 'operator_merge')`
- )
- .bind(crypto.randomUUID(), Date.now(), survivorId, loserId)
- );
-
add("usersDeleted", db.prepare(`DELETE FROM users WHERE id = ?`).bind(loserId));
if (backfillEmail) {
// A blank-or-NULL-email survivor acquires the email freed by the loser's
@@ -367,7 +448,7 @@ export async function mergeUsers(
const counts = emptyCounts();
for (const [key, index] of Object.entries(track) as [UserMergeCountKey, number][]) {
- counts[key] = results[index]?.meta.changes ?? 0;
+ counts[key] = (results[index]?.meta.changes ?? 0) / (RESULT_CHANGE_DIVISORS[key] ?? 1);
}
if (loser) {
// The users delete's reported `changes` includes any FK-cascaded rows;
@@ -438,7 +519,9 @@ async function previewCounts(
...operationCounts,
skillProfileItemsMerged: count(skillProfileItemsMerged),
roleAssignmentsRemoved: count(roleAssignments),
- auditEventsCreated: count(users),
+ // mergeUsers returns before previewing when the loser is absent, so an
+ // executed merge always writes exactly one audit event.
+ auditEventsCreated: 1,
canonicalEmailBackfilled,
usersDeleted: count(users),
};
diff --git a/packages/control-plane/test/integration/rbac-foundation.test.ts b/packages/control-plane/test/integration/rbac-foundation.test.ts
index bb73a61d8..a73bdb9ba 100644
--- a/packages/control-plane/test/integration/rbac-foundation.test.ts
+++ b/packages/control-plane/test/integration/rbac-foundation.test.ts
@@ -4,7 +4,16 @@ import {
PERMISSION_IDS,
permissionsForBuiltInRole,
} from "@open-inspect/shared/rbac";
-import { describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it } from "vitest";
+import { AuthorizationStore } from "../../src/db/authorization-store";
+import { AuthorizationService } from "../../src/authorization/service";
+import { cleanD1Tables } from "./cleanup";
+import { insertCanonicalUser } from "./identity-seed-helpers";
+
+const ACTOR_ID = "11111111111111111111111111111111";
+const TARGET_ID = "22222222222222222222222222222222";
+
+beforeEach(cleanD1Tables);
describe("RBAC foundation migration", () => {
it("seeds built-in roles without persisting their code-owned permissions", async () => {
@@ -24,4 +33,111 @@ describe("RBAC foundation migration", () => {
).toEqual({ count: 0 });
expect(permissionsForBuiltInRole("owner")).toHaveLength(PERMISSION_IDS.length);
});
+
+ it("rejects non-canonical system role identities and reserved IDs used as custom roles", async () => {
+ await expect(
+ env.DB.prepare(
+ `INSERT INTO roles (id, key, name, normalized_name, is_system)
+ VALUES ('role_system_alias', NULL, 'Alias', 'alias', 1)`
+ ).run()
+ ).rejects.toThrow();
+
+ await expect(
+ env.DB.prepare(
+ `UPDATE roles SET key = NULL, is_system = 0
+ WHERE id = 'role_builtin_owner'`
+ ).run()
+ ).rejects.toThrow();
+
+ await expect(
+ env.DB.prepare(
+ `UPDATE roles SET key = NULL
+ WHERE id = 'role_builtin_owner'`
+ ).run()
+ ).rejects.toThrow();
+ });
+
+ it("classifies missing roles and members through real D1 mutation SQL", async () => {
+ await insertCanonicalUser({ id: ACTOR_ID, email: "owner@example.com" });
+ await insertCanonicalUser({ id: TARGET_ID, email: "member@example.com" });
+ await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?")
+ .bind(BUILT_IN_ROLE_REGISTRY.owner.id, ACTOR_ID)
+ .run();
+ const store = new AuthorizationStore(env.DB);
+
+ await expect(
+ store.replaceMemberRole({
+ actorUserId: ACTOR_ID,
+ targetUserId: TARGET_ID,
+ roleId: "role_missing",
+ requestId: "missing-role",
+ now: 100,
+ })
+ ).resolves.toEqual({ status: "role_not_found" });
+ await expect(
+ store.replaceMemberRole({
+ actorUserId: ACTOR_ID,
+ targetUserId: "33333333333333333333333333333333",
+ roleId: BUILT_IN_ROLE_REGISTRY.viewer.id,
+ requestId: "missing-role-target",
+ now: 101,
+ })
+ ).resolves.toEqual({ status: "member_not_found" });
+ await expect(
+ store.replaceMemberStatus({
+ actorUserId: ACTOR_ID,
+ targetUserId: "33333333333333333333333333333333",
+ suspended: true,
+ requestId: "missing-status-target",
+ now: 102,
+ })
+ ).resolves.toEqual({ status: "member_not_found" });
+
+ const service = new AuthorizationService(env.DB);
+ await expect(
+ service.replaceMemberRole({
+ actorUserId: ACTOR_ID,
+ targetUserId: TARGET_ID,
+ roleId: "role_missing",
+ requestId: "missing-role-service",
+ })
+ ).rejects.toMatchObject({ status: 404, code: "role_not_found" });
+ await expect(
+ service.replaceMemberStatus({
+ actorUserId: ACTOR_ID,
+ targetUserId: "33333333333333333333333333333333",
+ suspended: true,
+ requestId: "missing-member-service",
+ })
+ ).rejects.toMatchObject({ status: 404, code: "member_not_found" });
+ });
+
+ it("applies and audits a member mutation through real D1 SQL", async () => {
+ await insertCanonicalUser({ id: ACTOR_ID, email: "owner@example.com" });
+ await insertCanonicalUser({ id: TARGET_ID, email: "member@example.com" });
+ await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?")
+ .bind(BUILT_IN_ROLE_REGISTRY.owner.id, ACTOR_ID)
+ .run();
+ const store = new AuthorizationStore(env.DB);
+
+ await expect(
+ store.replaceMemberRole({
+ actorUserId: ACTOR_ID,
+ targetUserId: TARGET_ID,
+ roleId: BUILT_IN_ROLE_REGISTRY.viewer.id,
+ requestId: "apply-role",
+ now: 200,
+ })
+ ).resolves.toEqual({ status: "applied" });
+ await expect(
+ env.DB.prepare(
+ `SELECT action, request_id, target_user_id_snapshot
+ FROM authorization_audit_events WHERE request_id = 'apply-role'`
+ ).first()
+ ).resolves.toEqual({
+ action: "workspace.member_role_updated",
+ request_id: "apply-role",
+ target_user_id_snapshot: TARGET_ID,
+ });
+ });
});
diff --git a/packages/control-plane/test/integration/user-merge.test.ts b/packages/control-plane/test/integration/user-merge.test.ts
index 43d6272c1..75e8d66ee 100644
--- a/packages/control-plane/test/integration/user-merge.test.ts
+++ b/packages/control-plane/test/integration/user-merge.test.ts
@@ -1,6 +1,7 @@
import { env } from "cloudflare:test";
import { beforeEach, describe, expect, it } from "vitest";
import { mergeUsers, UserMergeError } from "../../src/db/user-merge";
+import type { SqlDatabase, SqlResult, SqlStatement } from "../../src/db/sql-database";
import { cleanD1Tables } from "./cleanup";
import {
SEED_NOW_MS,
@@ -115,6 +116,7 @@ describe("mergeUsers", () => {
automationsCreatedRepointed: 1,
scmTokensRepointed: 1,
skillProfilesRepointed: 1,
+ skillCatalogGenerationsAdvanced: 1,
readStatesDeduped: 1,
readStatesRepointed: 1,
usersDeleted: 1,
@@ -158,6 +160,11 @@ describe("mergeUsers", () => {
).toEqual({ last_read_message_id: "msg-survivor" });
expect(await getUserRow(LOSER)).toBeNull();
expect(await countTableRows("users")).toBe(1);
+ expect(
+ await env.DB.prepare(
+ "SELECT generation FROM skills_catalog_state WHERE singleton = 1"
+ ).first()
+ ).toEqual({ generation: 1 });
expect(
await env.DB.prepare(
`SELECT principal_kind, actor_user_id_snapshot, actor_service_snapshot,
@@ -308,6 +315,88 @@ describe("mergeUsers", () => {
).toEqual({ shortcuts: "{}" });
});
+ it("preserves canonical attribution across provider accounts and managed skills", async () => {
+ await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
+ await insertCanonicalUser({ id: LOSER, email: null });
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO model_provider_accounts
+ (id, provider, display_name, status, created_by, updated_by, created_at, updated_at)
+ VALUES ('provider-account', 'openai', 'Personal', 'active', ?, ?, 1, 1)`
+ ).bind(LOSER, LOSER),
+ env.DB.prepare(
+ `INSERT INTO model_provider_account_defaults
+ (provider, provider_account_id, created_by, updated_by, created_at, updated_at)
+ VALUES ('openai', 'provider-account', ?, ?, 1, 1)`
+ ).bind(LOSER, LOSER),
+ env.DB.prepare(
+ `INSERT INTO skills
+ (id, name, enabled, created_by, updated_by, created_at, updated_at)
+ VALUES ('skill-1', 'Skill One', 1, ?, ?, 1, 1)`
+ ).bind(LOSER, LOSER),
+ env.DB.prepare(
+ `INSERT INTO skill_revisions
+ (id, skill_id, revision_number, revision_sha256, description, body,
+ metadata_json, total_bytes, created_by, created_at)
+ VALUES ('revision-1', 'skill-1', 1, ?, 'Description', 'Body', '{}', 4, ?, 1)`
+ ).bind("a".repeat(64), LOSER),
+ ]);
+ await env.DB.batch([
+ env.DB.prepare("UPDATE skills SET current_revision_id = 'revision-1' WHERE id = 'skill-1'"),
+ env.DB.prepare(
+ `INSERT INTO skill_assignments
+ (id, skill_id, scope_type, created_by, created_at)
+ VALUES ('assignment-1', 'skill-1', 'global', ?, 1)`
+ ).bind(LOSER),
+ ]);
+
+ const preview = await mergeUsers(env.DB, {
+ survivorId: SURVIVOR,
+ loserId: LOSER,
+ dryRun: true,
+ });
+ const result = await mergeUsers(env.DB, { survivorId: SURVIVOR, loserId: LOSER });
+
+ expect(preview.counts).toMatchObject({
+ providerAccountsCreatedRepointed: 1,
+ providerAccountsUpdatedRepointed: 1,
+ providerAccountDefaultsCreatedRepointed: 1,
+ providerAccountDefaultsUpdatedRepointed: 1,
+ skillsCreatedRepointed: 1,
+ skillsUpdatedRepointed: 1,
+ skillRevisionsCreatedRepointed: 1,
+ skillAssignmentsCreatedRepointed: 1,
+ });
+ expect(result.counts).toEqual(preview.counts);
+ expect(
+ await env.DB.prepare(
+ `SELECT created_by, updated_by FROM model_provider_accounts
+ WHERE id = 'provider-account'`
+ ).first()
+ ).toEqual({ created_by: SURVIVOR, updated_by: SURVIVOR });
+ expect(
+ await env.DB.prepare(
+ `SELECT created_by, updated_by FROM model_provider_account_defaults
+ WHERE provider = 'openai'`
+ ).first()
+ ).toEqual({ created_by: SURVIVOR, updated_by: SURVIVOR });
+ expect(
+ await env.DB.prepare(
+ `SELECT s.created_by, s.updated_by, r.created_by AS revision_created_by,
+ a.created_by AS assignment_created_by
+ FROM skills s
+ JOIN skill_revisions r ON r.id = 'revision-1'
+ JOIN skill_assignments a ON a.id = 'assignment-1'
+ WHERE s.id = 'skill-1'`
+ ).first()
+ ).toEqual({
+ created_by: SURVIVOR,
+ updated_by: SURVIVOR,
+ revision_created_by: SURVIVOR,
+ assignment_created_by: SURVIVOR,
+ });
+ });
+
it("keeps keyboard preference collision preview and execution counts aligned", async () => {
await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
await insertCanonicalUser({ id: LOSER, email: null });
@@ -355,6 +444,51 @@ describe("mergeUsers", () => {
expect(await countTableRows("users")).toBe(1);
});
+ it("rejects a suspended loser merging into an active survivor", async () => {
+ await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
+ await insertCanonicalUser({ id: LOSER, email: null });
+ await env.DB.prepare("UPDATE users SET suspended_at = 123 WHERE id = ?").bind(LOSER).run();
+
+ await expect(mergeUsers(env.DB, { survivorId: SURVIVOR, loserId: LOSER })).rejects.toThrow(
+ /suspension states/
+ );
+ expect(await getUserRow(LOSER)).not.toBeNull();
+ });
+
+ it("rolls back when role invariants change after preflight", async () => {
+ await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
+ await insertCanonicalUser({ id: LOSER, email: null });
+ let batchCount = 0;
+ const racingDatabase: SqlDatabase = {
+ prepare(query: string): SqlStatement {
+ return env.DB.prepare(query) as unknown as SqlStatement;
+ },
+ async batch(statements: SqlStatement[]): Promise[]> {
+ batchCount += 1;
+ if (batchCount === 2) {
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_viewer' WHERE user_id = ?"
+ )
+ .bind(SURVIVOR)
+ .run();
+ }
+ return env.DB.batch(statements as unknown as D1PreparedStatement[]) as Promise<
+ SqlResult[]
+ >;
+ },
+ };
+
+ await expect(
+ mergeUsers(racingDatabase, { survivorId: SURVIVOR, loserId: LOSER })
+ ).rejects.toThrow();
+ expect(await getUserRow(LOSER)).not.toBeNull();
+ expect(
+ await env.DB.prepare(
+ "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.user_merged'"
+ ).first()
+ ).toEqual({ count: 0 });
+ });
+
it("rejects a missing survivor and a self-merge", async () => {
await insertCanonicalUser({ id: LOSER, email: null });
diff --git a/packages/shared/src/rbac.test.ts b/packages/shared/src/rbac.test.ts
index 7e3853a9c..352098523 100644
--- a/packages/shared/src/rbac.test.ts
+++ b/packages/shared/src/rbac.test.ts
@@ -9,6 +9,7 @@ import {
resolveScopedPermission,
replaceMemberRoleInputSchema,
replaceMemberStatusInputSchema,
+ roleReferenceSchema,
} from "./rbac";
describe("RBAC registry", () => {
@@ -39,6 +40,25 @@ describe("RBAC registry", () => {
);
});
+ it("binds built-in role IDs and keys into one canonical identity", () => {
+ expect(
+ roleReferenceSchema.parse({ id: "role_builtin_owner", key: "owner", name: "Owner" })
+ ).toEqual({ id: "role_builtin_owner", key: "owner", name: "Owner" });
+ expect(
+ roleReferenceSchema.parse({ id: "role_custom_reviewer", key: null, name: "Reviewer" })
+ ).toEqual({ id: "role_custom_reviewer", key: null, name: "Reviewer" });
+
+ expect(() =>
+ roleReferenceSchema.parse({ id: "role_other", key: "owner", name: "Owner" })
+ ).toThrow();
+ expect(() =>
+ roleReferenceSchema.parse({ id: "role_builtin_owner", key: null, name: "Custom" })
+ ).toThrow();
+ expect(() =>
+ roleReferenceSchema.parse({ id: "role_builtin_member", key: "viewer", name: "Viewer" })
+ ).toThrow();
+ });
+
it("contains unique, sorted permission identifiers", () => {
expect(PERMISSION_IDS).toHaveLength(42);
expect(new Set(PERMISSION_IDS).size).toBe(PERMISSION_IDS.length);
diff --git a/packages/shared/src/rbac.ts b/packages/shared/src/rbac.ts
index 5ebf1af74..caad28048 100644
--- a/packages/shared/src/rbac.ts
+++ b/packages/shared/src/rbac.ts
@@ -25,6 +25,8 @@ export const BUILT_IN_ROLE_REGISTRY = {
export type BuiltInRoleKey = keyof typeof BUILT_IN_ROLE_REGISTRY;
/** Built-in role keys in canonical registry order. */
export const BUILT_IN_ROLE_KEYS = Object.keys(BUILT_IN_ROLE_REGISTRY) as BuiltInRoleKey[];
+/** Stable IDs reserved for system-defined roles. */
+export const BUILT_IN_ROLE_IDS = Object.values(BUILT_IN_ROLE_REGISTRY).map((role) => role.id);
/** Canonical permission identifiers accepted by the RBAC policy and persistence layers. */
export const PERMISSION_IDS = [
@@ -155,21 +157,52 @@ export function isCustomRolePermission(permission: PermissionId): boolean {
return permission !== "workspace.transfer_ownership";
}
+const roleNameSchema = z.string().min(1);
+const roleReferenceShape = {
+ id: z.string().min(1),
+ key: builtInRoleKeySchema.nullable(),
+ name: roleNameSchema,
+};
+
+function validateRoleIdentity(
+ role: { id: string; key: BuiltInRoleKey | null },
+ context: z.RefinementCtx
+): void {
+ if (role.key === null) {
+ if ((BUILT_IN_ROLE_IDS as readonly string[]).includes(role.id)) {
+ context.addIssue({
+ code: "custom",
+ path: ["id"],
+ message: "Built-in role IDs require their canonical key",
+ });
+ }
+ return;
+ }
+ if (role.id !== BUILT_IN_ROLE_REGISTRY[role.key].id) {
+ context.addIssue({
+ code: "custom",
+ path: ["id"],
+ message: "Built-in role keys require their canonical ID",
+ });
+ }
+}
+
/** Validates the role identity embedded in authorization responses. */
export const roleReferenceSchema = z
- .object({
- id: z.string().min(1),
- key: builtInRoleKeySchema.nullable(),
- name: z.string().min(1),
- })
- .strict();
+ .object(roleReferenceShape)
+ .strict()
+ .superRefine(validateRoleIdentity);
/** Validates an administrative role view with effective grants and assignment count. */
-export const roleSummarySchema = roleReferenceSchema.extend({
- description: z.string().nullable(),
- permissions: z.array(permissionIdSchema),
- assignmentCount: z.number().int().nonnegative(),
-});
+export const roleSummarySchema = z
+ .object({
+ ...roleReferenceShape,
+ description: z.string().nullable(),
+ permissions: z.array(permissionIdSchema),
+ assignmentCount: z.number().int().nonnegative(),
+ })
+ .strict()
+ .superRefine(validateRoleIdentity);
/** Validates a user's role, suspension state, and currently effective permissions. */
export const effectiveAuthorizationSchema = z
@@ -213,6 +246,8 @@ export const replaceMemberStatusInputSchema = z
/** Administrative role data with effective grants and current assignment count. */
export type RoleSummary = z.infer;
+/** A built-in or custom role identity with canonical ID/key pairing. */
+export type RoleReference = z.infer;
/** The authorization state used to make permission decisions for a user. */
export type EffectiveAuthorization = z.infer;
/** A workspace member and their current RBAC assignment state. */
diff --git a/scripts/bootstrap-workspace-owner.test.ts b/scripts/bootstrap-workspace-owner.test.ts
index 58eefd90b..5f9f9eeb3 100644
--- a/scripts/bootstrap-workspace-owner.test.ts
+++ b/scripts/bootstrap-workspace-owner.test.ts
@@ -1,7 +1,8 @@
import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import { DatabaseSync } from "node:sqlite";
-import { buildBootstrapSql, parseArgs } from "./bootstrap-workspace-owner.ts";
+import { buildBootstrapSql, parseArgs, run } from "./bootstrap-workspace-owner.ts";
const USER_ID = "11111111111111111111111111111111";
const OTHER_USER_ID = "22222222222222222222222222222222";
@@ -308,3 +309,68 @@ describe("Owner bootstrap SQL", () => {
assert.match(generated, /SELECT 1 FROM authorization_audit_events WHERE id = 'audit-exact'/);
});
});
+
+describe("Owner bootstrap orchestration", () => {
+ it("accepts only the execution response bound to this invocation's audit", async () => {
+ let calls = 0;
+ await run(
+ { database: "workspace", userId: USER_ID, execute: true },
+ {
+ randomUUID: () => "audit-exact",
+ now: () => 123,
+ runWrangler: (_database, operation) => {
+ calls += 1;
+ if (operation[0] === "--command") {
+ return JSON.stringify([
+ { success: true, results: [{ report: "preflight", status: "ready" }] },
+ ]);
+ }
+ assert.equal(operation[0], "--file");
+ const sqlPath = operation[1];
+ assert.ok(sqlPath);
+ const generated = readFileSync(sqlPath, "utf8");
+ assert.match(generated, /audit-exact/);
+ assert.match(generated, /123/);
+ return JSON.stringify([
+ {
+ success: true,
+ results: [
+ {
+ report: "postcondition",
+ status: "executed",
+ audit_written: 1,
+ },
+ ],
+ },
+ ]);
+ },
+ }
+ );
+
+ assert.equal(calls, 2);
+ });
+
+ it("reports a concurrent winner instead of claiming this invocation completed", async () => {
+ await assert.rejects(
+ run(
+ { database: "workspace", userId: USER_ID, execute: true },
+ {
+ randomUUID: () => "audit-loser",
+ now: () => 123,
+ runWrangler: (_database, operation) =>
+ JSON.stringify([
+ {
+ success: true,
+ results: [
+ operation[0] === "--command"
+ ? { report: "preflight", status: "ready" }
+ : { report: "postcondition", status: "no-op", audit_written: 0 },
+ ],
+ },
+ ]),
+ }
+ ),
+ /ownership changed concurrently/
+ );
+ });
+});
diff --git a/scripts/bootstrap-workspace-owner.ts b/scripts/bootstrap-workspace-owner.ts
index e0a1ac66b..50976d0a6 100644
--- a/scripts/bootstrap-workspace-owner.ts
+++ b/scripts/bootstrap-workspace-owner.ts
@@ -207,6 +207,15 @@ interface WranglerResult {
success?: boolean;
}
+type WranglerRunner = (database: string, operation: readonly string[]) => string;
+
+/** Injectable side effects for deterministic bootstrap orchestration tests. */
+export interface BootstrapRunDependencies {
+ runWrangler?: WranglerRunner;
+ randomUUID?: () => string;
+ now?: () => number;
+}
+
function reportRows(stdout: string): Array> {
const parsed = JSON.parse(stdout) as WranglerResult[];
const rows = parsed.flatMap((result) => result.results ?? []).filter((row) => row.report);
@@ -226,18 +235,22 @@ function runWrangler(database: string, operation: readonly string[]): string {
return child.stdout;
}
-function preflight(database: string, userId: string): string {
+function preflight(database: string, userId: string, runner: WranglerRunner): string {
const sql = buildBootstrapSql({ userId, execute: false, auditId: "unused", now: 0 });
- const rows = reportRows(runWrangler(database, ["--command", sql]));
+ const rows = reportRows(runner(database, ["--command", sql]));
const status = rows.find((row) => row.report === "preflight")?.status;
if (typeof status !== "string") throw new Error("Wrangler returned no Owner bootstrap preflight");
return status;
}
/** Run the remote Owner bootstrap workflow and verify its postcondition. */
-export async function run(options: BootstrapCliOptions): Promise {
+export async function run(
+ options: BootstrapCliOptions,
+ dependencies: BootstrapRunDependencies = {}
+): Promise {
+ const runner = dependencies.runWrangler ?? runWrangler;
console.error(`${options.execute ? "Executing" : "Dry-running"} Owner bootstrap on remote D1...`);
- const status = preflight(options.database, options.userId);
+ const status = preflight(options.database, options.userId, runner);
if (status === "refused") throw new Error("Owner bootstrap preflight was refused");
if (status === "no-op") return;
if (!options.execute) {
@@ -247,24 +260,31 @@ export async function run(options: BootstrapCliOptions): Promise {
const directory = await mkdtemp(join(tmpdir(), "open-inspect-owner-bootstrap-"));
const sqlPath = join(directory, "bootstrap.sql");
+ let executionRows: Array>;
try {
+ const auditId = dependencies.randomUUID?.() ?? crypto.randomUUID();
+ const now = dependencies.now?.() ?? Date.now();
await writeFile(
sqlPath,
buildBootstrapSql({
userId: options.userId,
execute: true,
- auditId: crypto.randomUUID(),
- now: Date.now(),
+ auditId,
+ now,
}),
{ encoding: "utf8", mode: 0o600 }
);
- runWrangler(options.database, ["--file", sqlPath]);
+ executionRows = reportRows(runner(options.database, ["--file", sqlPath]));
} finally {
await rm(directory, { recursive: true, force: true });
}
- if (preflight(options.database, options.userId) !== "no-op") {
- throw new Error("Owner bootstrap postcondition verification failed");
+ const postcondition = executionRows.find((row) => row.report === "postcondition");
+ if (postcondition?.status === "no-op") {
+ throw new Error("Owner bootstrap did not execute because ownership changed concurrently");
+ }
+ if (postcondition?.status !== "executed" || Number(postcondition.audit_written) !== 1) {
+ throw new Error("Owner bootstrap execution did not prove its exact audit and assignment");
}
console.error(
"Owner bootstrap command completed; verify /health reports ownerAssignment=present."
diff --git a/scripts/merge-split-users.ts b/scripts/merge-split-users.ts
index fe1fcee8c..aad4ce82d 100644
--- a/scripts/merge-split-users.ts
+++ b/scripts/merge-split-users.ts
@@ -11,9 +11,8 @@
*
* Dry-run is the default — it prints exact per-table counts and writes
* nothing. Pass --execute to apply. The merge is idempotent: re-running a
- * completed merge is a zero-count no-op, so a partially-applied run (the
- * wrangler transport executes statements sequentially, not atomically) is
- * repaired by running the script again.
+ * completed merge is a zero-count no-op. Execute mode submits the complete
+ * graph mutation as one atomic D1 SQL file.
*
* Usage:
* node --experimental-transform-types scripts/merge-split-users.ts \
@@ -26,6 +25,9 @@
*/
import { spawnSync } from "node:child_process";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import type {
SqlDatabase,
SqlResult,
@@ -109,17 +111,20 @@ class WranglerD1Database implements SqlDatabase {
return statement;
}
- // Deviation from the SqlDatabase.batch contract: all statements go to D1
- // in one wrangler submission, but cross-statement atomicity is not
- // guaranteed by this transport (scripts/d1-migrate.sh documents D1
- // multi-statement submissions as atomic; we deliberately do not rely on
- // it). The merge tolerates this for every statement except the final email
- // backfill, whose input row is deleted earlier in the batch: re-running
- // repairs any other partial application, and the CLI prints a recovery
- // record before executing to cover that one residual case.
+ // D1 executes one --file submission atomically. Keep this adapter aligned
+ // with SqlDatabase.batch rather than emulating a batch through independent
+ // or non-transactional command calls.
async batch(statements: SqlStatement[]): Promise[]> {
+ if (statements.length === 0) return [];
const rendered = statements.map((entry) => (entry as { render(): string }).render());
- return this.execute(rendered).map((result) => toSqlResult(result));
+ const directory = mkdtempSync(join(tmpdir(), "open-inspect-user-merge-"));
+ const sqlPath = join(directory, "merge.sql");
+ try {
+ writeFileSync(sqlPath, `${rendered.join(";\n")};\n`, { encoding: "utf8", mode: 0o600 });
+ return this.executeOperation(["--file", sqlPath]).map((result) => toSqlResult(result));
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
}
private execute(statements: string[]): WranglerQueryResult[] {
@@ -127,6 +132,10 @@ class WranglerD1Database implements SqlDatabase {
if (this.verbose) {
for (const statement of statements) console.error(`[sql] ${statement}`);
}
+ return this.executeOperation(["--command", statements.join(";\n")]);
+ }
+
+ private executeOperation(operation: string[]): WranglerQueryResult[] {
const args = [
"wrangler",
"d1",
@@ -134,8 +143,7 @@ class WranglerD1Database implements SqlDatabase {
this.databaseName,
this.remote ? "--remote" : "--local",
"--json",
- "--command",
- statements.join(";\n"),
+ ...operation,
];
const child = spawnSync("npx", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
if (child.status !== 0) {
@@ -211,25 +219,6 @@ async function main(): Promise {
const options = parseArgs(process.argv.slice(2));
const db = new WranglerD1Database(options.database, !options.local, options.verbose);
- if (options.execute) {
- // Durable recovery record: the final email backfill is the one statement
- // a re-run cannot repair, because its input (the loser row) is deleted by
- // the statement before it. Everything needed to restore that step by hand
- // is printed here, before anything executes.
- const loserRecord = await db
- .prepare(`SELECT id, email, email_verified FROM users WHERE id = ?`)
- .bind(options.loserId)
- .first<{ id: string; email: string | null; email_verified: number }>();
- console.error(`Recovery record (loser row): ${JSON.stringify(loserRecord)}`);
- console.error(
- "Retain this until the merge is verified. If a run fails partway, re-run it — " +
- "that repairs every step except the final email backfill. If the survivor is " +
- "left without the loser's email, restore it manually:\n" +
- ` UPDATE users SET email = , email_verified = ` +
- `WHERE id = '${options.survivorId}' AND email IS NULL;\n`
- );
- }
-
const result = await mergeUsers(db, {
survivorId: options.survivorId,
loserId: options.loserId,
diff --git a/terraform/d1/migrations/0071_rbac_foundation.sql b/terraform/d1/migrations/0071_rbac_foundation.sql
index 4297c3da2..5c093b8dc 100644
--- a/terraform/d1/migrations/0071_rbac_foundation.sql
+++ b/terraform/d1/migrations/0071_rbac_foundation.sql
@@ -8,8 +8,18 @@ CREATE TABLE roles (
description TEXT,
is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0, 1)),
CHECK (
- (is_system = 1 AND key IN ('owner', 'administrator', 'member', 'viewer'))
- OR (is_system = 0 AND key IS NULL)
+ (is_system = 1 AND key IS NOT NULL AND (
+ (id = 'role_builtin_owner' AND key = 'owner')
+ OR (id = 'role_builtin_administrator' AND key = 'administrator')
+ OR (id = 'role_builtin_member' AND key = 'member')
+ OR (id = 'role_builtin_viewer' AND key = 'viewer')
+ ))
+ OR (is_system = 0 AND key IS NULL AND id NOT IN (
+ 'role_builtin_owner',
+ 'role_builtin_administrator',
+ 'role_builtin_member',
+ 'role_builtin_viewer'
+ ))
)
);
From 675a55449cd45760d2e5c74d3315e021b85eddd0 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Sun, 30 Aug 2026 23:02:57 -0700
Subject: [PATCH 7/9] fix(rbac): preserve merge batch result contract
---
.github/workflows/ci.yml | 3 +
package.json | 1 +
packages/control-plane/src/db/user-merge.ts | 4 +-
.../test/integration/user-merge.test.ts | 17 ++++++
scripts/merge-split-users.test.ts | 51 ++++++++++++++++
scripts/merge-split-users.ts | 60 ++++++++++++-------
6 files changed, 111 insertions(+), 25 deletions(-)
create mode 100644 scripts/merge-split-users.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f70ac230d..e62952766 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -82,6 +82,9 @@ jobs:
- name: Test Owner bootstrap CLI
run: npm run test:rbac-bootstrap-owner
+ - name: Test user merge CLI
+ run: npm run test:user-merge-cli
+
- name: Check Prettier formatting
run: npm run format:check
diff --git a/package.json b/package.json
index 5c7a63432..5e6c2bbee 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"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:user-merge-cli": "node --experimental-transform-types --test scripts/merge-split-users.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",
diff --git a/packages/control-plane/src/db/user-merge.ts b/packages/control-plane/src/db/user-merge.ts
index 95e3d190c..da626a47d 100644
--- a/packages/control-plane/src/db/user-merge.ts
+++ b/packages/control-plane/src/db/user-merge.ts
@@ -319,7 +319,7 @@ export async function mergeUsers(
if (survivorRole && loserRole && survivorRole.role_id !== loserRole.role_id) {
throw new UserMergeError("Resolve conflicting user roles before merging");
}
- if (survivor.suspended_at !== loser.suspended_at) {
+ if ((survivor.suspended_at === null) !== (loser.suspended_at === null)) {
throw new UserMergeError("Resolve conflicting user suspension states before merging");
}
if (loserRole?.role_key === "owner" && survivor.suspended_at !== null) {
@@ -377,7 +377,7 @@ export async function mergeUsers(
JOIN roles role ON role.id = loser_assignment.role_id
WHERE survivor.id = ?
AND survivor_assignment.role_id = loser_assignment.role_id
- AND survivor.suspended_at IS loser.suspended_at
+ AND (survivor.suspended_at IS NULL) = (loser.suspended_at IS NULL)
AND (role.key IS NULL OR role.key <> 'owner' OR survivor.suspended_at IS NULL)
) THEN ? ELSE NULL END,
'user-merge', 'service', 'control-plane',
diff --git a/packages/control-plane/test/integration/user-merge.test.ts b/packages/control-plane/test/integration/user-merge.test.ts
index 75e8d66ee..1821b115f 100644
--- a/packages/control-plane/test/integration/user-merge.test.ts
+++ b/packages/control-plane/test/integration/user-merge.test.ts
@@ -455,6 +455,23 @@ describe("mergeUsers", () => {
expect(await getUserRow(LOSER)).not.toBeNull();
});
+ it("merges two suspended users even when their suspension timestamps differ", async () => {
+ await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
+ await insertCanonicalUser({ id: LOSER, email: null });
+ await env.DB.prepare("UPDATE users SET suspended_at = 123 WHERE id = ?").bind(SURVIVOR).run();
+ await env.DB.prepare("UPDATE users SET suspended_at = 456 WHERE id = ?").bind(LOSER).run();
+
+ await expect(
+ mergeUsers(env.DB, { survivorId: SURVIVOR, loserId: LOSER })
+ ).resolves.toMatchObject({
+ counts: { usersDeleted: 1 },
+ });
+ expect(await getUserRow(LOSER)).toBeNull();
+ expect(
+ await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(SURVIVOR).first()
+ ).toEqual({ suspended_at: 123 });
+ });
+
it("rolls back when role invariants change after preflight", async () => {
await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
await insertCanonicalUser({ id: LOSER, email: null });
diff --git a/scripts/merge-split-users.test.ts b/scripts/merge-split-users.test.ts
new file mode 100644
index 000000000..e5e4c1f3a
--- /dev/null
+++ b/scripts/merge-split-users.test.ts
@@ -0,0 +1,51 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { WranglerD1Database, type WranglerRunner } from "./merge-split-users.ts";
+
+function result(results: Record[], changes = 0): string {
+ return JSON.stringify([{ success: true, results, meta: { changes } }]);
+}
+
+describe("Wrangler user-merge database adapter", () => {
+ it("uses the result-bearing command batch and preserves positional results", async () => {
+ let invokedArgs: string[] = [];
+ const runner: WranglerRunner = (args) => {
+ invokedArgs = args;
+ return {
+ status: 0,
+ stderr: "",
+ stdout: JSON.stringify([
+ { success: true, results: [{ role_id: "survivor-role" }], meta: { changes: 0 } },
+ { success: true, results: [{ role_id: "loser-role" }], meta: { changes: 0 } },
+ ]),
+ };
+ };
+ const database = new WranglerD1Database("workspace", true, false, runner);
+
+ const results = await database.batch([
+ database.prepare("SELECT role_id FROM assignments WHERE user_id = ?").bind("survivor"),
+ database.prepare("SELECT role_id FROM assignments WHERE user_id = ?").bind("loser"),
+ ]);
+
+ assert.deepEqual(
+ results.map((entry) => entry.results[0]),
+ [{ role_id: "survivor-role" }, { role_id: "loser-role" }]
+ );
+ assert.ok(invokedArgs.includes("--command"));
+ assert.ok(!invokedArgs.includes("--file"));
+ });
+
+ it("fails loudly if Wrangler collapses a batch into one aggregate result", async () => {
+ const runner: WranglerRunner = () => ({
+ status: 0,
+ stderr: "",
+ stdout: result([{ "Total queries executed": 2 }]),
+ });
+ const database = new WranglerD1Database("workspace", true, false, runner);
+
+ await assert.rejects(
+ database.batch([database.prepare("SELECT 1"), database.prepare("SELECT 2")]),
+ /returned 1 results for 2 batched statements/
+ );
+ });
+});
diff --git a/scripts/merge-split-users.ts b/scripts/merge-split-users.ts
index aad4ce82d..63306815f 100644
--- a/scripts/merge-split-users.ts
+++ b/scripts/merge-split-users.ts
@@ -12,7 +12,7 @@
* Dry-run is the default — it prints exact per-table counts and writes
* nothing. Pass --execute to apply. The merge is idempotent: re-running a
* completed merge is a zero-count no-op. Execute mode submits the complete
- * graph mutation as one atomic D1 SQL file.
+ * graph mutation as one result-bearing D1 batch.
*
* Usage:
* node --experimental-transform-types scripts/merge-split-users.ts \
@@ -25,9 +25,8 @@
*/
import { spawnSync } from "node:child_process";
-import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
+import { resolve } from "node:path";
+import { pathToFileURL } from "node:url";
import type {
SqlDatabase,
SqlResult,
@@ -45,6 +44,19 @@ interface WranglerQueryResult {
meta?: { changes?: number };
}
+/** Minimal process result used to test Wrangler orchestration without spawning. */
+export interface WranglerProcessResult {
+ status: number | null;
+ stdout: string;
+ stderr: string;
+}
+
+/** Injectable runner for Wrangler CLI orchestration tests. */
+export type WranglerRunner = (args: string[]) => WranglerProcessResult;
+
+const runWrangler: WranglerRunner = (args) =>
+ spawnSync("npx", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
+
function sqlLiteral(value: unknown): string {
if (value === null || value === undefined) return "NULL";
if (typeof value === "number") {
@@ -76,11 +88,12 @@ function inlineParams(sql: string, params: unknown[]): string {
return rendered;
}
-class WranglerD1Database implements SqlDatabase {
+export class WranglerD1Database implements SqlDatabase {
constructor(
private readonly databaseName: string,
private readonly remote: boolean,
- private readonly verbose: boolean
+ private readonly verbose: boolean,
+ private readonly runner: WranglerRunner = runWrangler
) {}
prepare(query: string): SqlStatement {
@@ -111,20 +124,18 @@ class WranglerD1Database implements SqlDatabase {
return statement;
}
- // D1 executes one --file submission atomically. Keep this adapter aligned
- // with SqlDatabase.batch rather than emulating a batch through independent
- // or non-transactional command calls.
+ // Remote --command sends semicolon-separated statements to D1's /query
+ // batch API. D1 executes the batch transactionally and Wrangler preserves
+ // one positional result (including meta.changes) per statement.
async batch(statements: SqlStatement[]): Promise[]> {
- if (statements.length === 0) return [];
const rendered = statements.map((entry) => (entry as { render(): string }).render());
- const directory = mkdtempSync(join(tmpdir(), "open-inspect-user-merge-"));
- const sqlPath = join(directory, "merge.sql");
- try {
- writeFileSync(sqlPath, `${rendered.join(";\n")};\n`, { encoding: "utf8", mode: 0o600 });
- return this.executeOperation(["--file", sqlPath]).map((result) => toSqlResult(result));
- } finally {
- rmSync(directory, { recursive: true, force: true });
+ const results = this.execute(rendered);
+ if (results.length !== statements.length) {
+ throw new Error(
+ `Wrangler returned ${results.length} results for ${statements.length} batched statements`
+ );
}
+ return results.map((result) => toSqlResult(result));
}
private execute(statements: string[]): WranglerQueryResult[] {
@@ -145,7 +156,7 @@ class WranglerD1Database implements SqlDatabase {
"--json",
...operation,
];
- const child = spawnSync("npx", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
+ const child = this.runner(args);
if (child.status !== 0) {
throw new Error(`wrangler d1 execute failed:\n${child.stderr || child.stdout}`);
}
@@ -247,8 +258,11 @@ async function main(): Promise {
}
}
-main().catch((error: unknown) => {
- const message = error instanceof UserMergeError ? error.message : String(error);
- console.error(message);
- process.exitCode = 1;
-});
+const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null;
+if (invokedPath === import.meta.url) {
+ main().catch((error: unknown) => {
+ const message = error instanceof UserMergeError ? error.message : String(error);
+ console.error(message);
+ process.exitCode = 1;
+ });
+}
From 42587093de1fc04867c90c3157ec88e29dbef386 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Mon, 31 Aug 2026 01:04:00 -0700
Subject: [PATCH 8/9] fix: address workspace administration review feedback
---
packages/control-plane/src/routes/rbac.ts | 28 +++--
.../test/integration/rbac-routes.test.ts | 36 +++---
.../web/src/components/app-destinations.ts | 16 ++-
.../components/global-command-menu.test.tsx | 10 ++
.../src/components/global-command-menu.tsx | 5 +-
.../src/components/session-sidebar.test.tsx | 20 ++-
.../web/src/components/session-sidebar.tsx | 8 +-
.../settings/data-controls-settings.test.tsx | 21 +++-
.../settings/data-controls-settings.tsx | 29 +++--
.../settings/settings-registry.test.ts | 25 ++++
.../components/settings/settings-registry.ts | 20 ++-
.../settings/settings-shell.test.tsx | 11 ++
.../components/settings/settings-shell.tsx | 10 +-
.../skills-settings/skill-details.tsx | 114 ++++++++++++++++++
.../skills-settings/skills-catalog.test.tsx | 56 ++++++++-
.../skills-settings/skills-catalog.tsx | 8 +-
.../settings/workspace-settings.test.tsx | 56 +++++++++
.../settings/workspace-settings.tsx | 16 ++-
.../use-workspace-administration.test.tsx | 25 +++-
.../src/hooks/use-workspace-administration.ts | 72 +++++++++--
packages/web/src/lib/auth-session.tsx | 5 +
21 files changed, 526 insertions(+), 65 deletions(-)
create mode 100644 packages/web/src/components/settings/skills-settings/skill-details.tsx
diff --git a/packages/control-plane/src/routes/rbac.ts b/packages/control-plane/src/routes/rbac.ts
index 919cb0304..78ccb528b 100644
--- a/packages/control-plane/src/routes/rbac.ts
+++ b/packages/control-plane/src/routes/rbac.ts
@@ -40,6 +40,14 @@ function rbacErrorResponse(cause: unknown): Response {
return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503);
}
+function decodePathSegment(value: string): string | null {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return null;
+ }
+}
+
async function handleGetCurrentAuthorization(
_request: Request,
_env: Env,
@@ -76,7 +84,9 @@ async function handleGetRole(
): Promise {
const service = new AuthorizationService(ctx.db);
try {
- const role = await service.getRole(decodeURIComponent(match.groups!.id));
+ const roleId = decodePathSegment(match.groups!.id);
+ if (roleId === null) return error("Invalid role ID", 400);
+ const role = await service.getRole(roleId);
return role ? json(role) : error("Role not found", 404);
} catch (cause) {
return rbacErrorResponse(cause);
@@ -103,8 +113,10 @@ async function handleReplaceMemberRole(
match: RegExpMatchArray,
ctx: UserRouteContext
): Promise {
- const targetUserId = decodeURIComponent(match.groups!.id);
- if (!isCanonicalUserId(targetUserId)) return error("Invalid user ID", 400);
+ const targetUserId = decodePathSegment(match.groups!.id);
+ if (targetUserId === null || !isCanonicalUserId(targetUserId)) {
+ return error("Invalid user ID", 400);
+ }
const body = await parseJsonBody(request);
if (body instanceof Response) return body;
const service = new AuthorizationService(ctx.db);
@@ -116,7 +128,7 @@ async function handleReplaceMemberRole(
actorUserId: ctx.principal.userId,
requestId: ctx.request_id,
});
- return json(await service.getEffectiveAuthorization(targetUserId));
+ return new Response(null, { status: 204 });
} catch (cause) {
return rbacErrorResponse(cause);
}
@@ -128,8 +140,10 @@ async function handleReplaceMemberStatus(
match: RegExpMatchArray,
ctx: UserRouteContext
): Promise {
- const targetUserId = decodeURIComponent(match.groups!.id);
- if (!isCanonicalUserId(targetUserId)) return error("Invalid user ID", 400);
+ const targetUserId = decodePathSegment(match.groups!.id);
+ if (targetUserId === null || !isCanonicalUserId(targetUserId)) {
+ return error("Invalid user ID", 400);
+ }
const body = await parseJsonBody(request);
if (body instanceof Response) return body;
const service = new AuthorizationService(ctx.db);
@@ -141,7 +155,7 @@ async function handleReplaceMemberStatus(
actorUserId: ctx.principal.userId,
requestId: ctx.request_id,
});
- return json(await service.getEffectiveAuthorization(targetUserId));
+ return new Response(null, { status: 204 });
} catch (cause) {
return rbacErrorResponse(cause);
}
diff --git a/packages/control-plane/test/integration/rbac-routes.test.ts b/packages/control-plane/test/integration/rbac-routes.test.ts
index da0bf6514..23ec8b8dd 100644
--- a/packages/control-plane/test/integration/rbac-routes.test.ts
+++ b/packages/control-plane/test/integration/rbac-routes.test.ts
@@ -368,7 +368,7 @@ describe("RBAC routes", () => {
headers: { "Content-Type": "application/json" },
});
- expect(response.status).toBe(200);
+ expect(response.status).toBe(204);
expect(
await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(ownerId).first()
).toEqual({ suspended_at: expect.any(Number) });
@@ -396,8 +396,7 @@ describe("RBAC routes", () => {
headers: { "Content-Type": "application/json" },
});
- expect(response.status).toBe(200);
- await expect(response.json()).resolves.toMatchObject({ role: { key: "administrator" } });
+ expect(response.status).toBe(204);
expect(
await env.DB.prepare(
"SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.member_role_updated'"
@@ -425,24 +424,23 @@ describe("RBAC routes", () => {
).toEqual({ key: "owner" });
});
- it("derives Owner bootstrap health from an unsuspended Owner assignment", async () => {
- const pending = await SELF.fetch("https://cp.test/health");
- await expect(pending.json()).resolves.toMatchObject({
- rbac: { ownerAssignment: "missing" },
- });
+ it.each(["role", "status"] as const)(
+ "rejects malformed percent encoding in member %s routes",
+ async (operation) => {
+ await seedOwner();
- const ownerId = await seedOwner();
- const complete = await SELF.fetch("https://cp.test/health");
- await expect(complete.json()).resolves.toMatchObject({
- rbac: { ownerAssignment: "present" },
- });
+ const response = await serviceFetch(`https://cp.test/members/%/${operation}`, {
+ method: "PUT",
+ body: JSON.stringify(
+ operation === "role" ? { roleId: "role_builtin_member" } : { suspended: true }
+ ),
+ headers: { "Content-Type": "application/json" },
+ });
- await env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?").bind(ownerId).run();
- const suspended = await SELF.fetch("https://cp.test/health");
- await expect(suspended.json()).resolves.toMatchObject({
- rbac: { ownerAssignment: "missing" },
- });
- });
+ expect(response.status).toBe(400);
+ await expect(response.json()).resolves.toEqual({ error: "Invalid user ID" });
+ }
+ );
it("requires an explicit unsuspended Owner assignment before merging an Owner", async () => {
const store = new UserStore(sqlDatabase(env.DB));
diff --git a/packages/web/src/components/app-destinations.ts b/packages/web/src/components/app-destinations.ts
index 4b78dd323..2e418cd7a 100644
--- a/packages/web/src/components/app-destinations.ts
+++ b/packages/web/src/components/app-destinations.ts
@@ -1,11 +1,21 @@
import { AutomationsIcon, DataControlsIcon, SettingsIcon } from "@/components/ui/icons";
+import type { PermissionId } from "@open-inspect/shared/rbac";
+import type { ComponentType } from "react";
+
+export interface AppDestination {
+ label: string;
+ description: string;
+ href: string;
+ icon: ComponentType<{ className?: string }>;
+ requiredPermission?: PermissionId;
+}
export const SETTINGS_DESTINATION = {
label: "Settings",
description: "Configure Open Inspect",
href: "/settings",
icon: SettingsIcon,
-} as const;
+} as const satisfies AppDestination;
export const PRIMARY_APP_DESTINATIONS = [
{
@@ -13,13 +23,15 @@ export const PRIMARY_APP_DESTINATIONS = [
description: "Manage scheduled and event-triggered work",
href: "/automations",
icon: AutomationsIcon,
+ requiredPermission: "automations.read",
},
{
label: "Analytics",
description: "View usage across sessions, repositories, and users",
href: "/analytics",
icon: DataControlsIcon,
+ requiredPermission: "analytics.read",
},
-] as const;
+] as const satisfies readonly AppDestination[];
export const APP_DESTINATIONS = [SETTINGS_DESTINATION, ...PRIMARY_APP_DESTINATIONS] as const;
diff --git a/packages/web/src/components/global-command-menu.test.tsx b/packages/web/src/components/global-command-menu.test.tsx
index 448943f91..0a8531c62 100644
--- a/packages/web/src/components/global-command-menu.test.tsx
+++ b/packages/web/src/components/global-command-menu.test.tsx
@@ -91,6 +91,16 @@ describe("GlobalCommandMenu", () => {
expect(screen.queryByText("Start a coding session")).not.toBeInTheDocument();
});
+ it("omits application destinations without their read permissions", () => {
+ mocks.allowedPermissions = new Set();
+
+ renderMenu();
+
+ expect(screen.queryByText("Automations")).not.toBeInTheDocument();
+ expect(screen.queryByText("Analytics")).not.toBeInTheDocument();
+ expect(screen.getByText("Configure Open Inspect")).toBeInTheDocument();
+ });
+
it("selects Analytics from the keyboard", async () => {
const user = userEvent.setup();
const { onNavigate, onOpenChange } = renderMenu();
diff --git a/packages/web/src/components/global-command-menu.tsx b/packages/web/src/components/global-command-menu.tsx
index 5eb93d47a..aa073671c 100644
--- a/packages/web/src/components/global-command-menu.tsx
+++ b/packages/web/src/components/global-command-menu.tsx
@@ -116,7 +116,10 @@ export function GlobalCommandMenu({
},
]
: []),
- ...APP_DESTINATIONS.map(({ label, description, href, icon: Icon }) => ({
+ ...APP_DESTINATIONS.filter(
+ (destination) =>
+ !("requiredPermission" in destination) || hasPermission(destination.requiredPermission)
+ ).map(({ label, description, href, icon: Icon }) => ({
label,
description,
Icon,
diff --git a/packages/web/src/components/session-sidebar.test.tsx b/packages/web/src/components/session-sidebar.test.tsx
index 2d2738a39..ecdcb57ee 100644
--- a/packages/web/src/components/session-sidebar.test.tsx
+++ b/packages/web/src/components/session-sidebar.test.tsx
@@ -8,8 +8,9 @@ import { SessionSidebar } from "./session-sidebar";
expect.extend(matchers);
-const { mockHook } = vi.hoisted(() => ({
+const { mockHook, authorization } = vi.hoisted(() => ({
mockHook: vi.fn(),
+ authorization: { permissions: null as Set | null },
}));
vi.mock("@/hooks/use-sidebar-sessions", () => ({ useSidebarSessions: mockHook }));
@@ -19,6 +20,12 @@ vi.mock("@/lib/auth-session", () => ({
}));
vi.mock("@/hooks/use-media-query", () => ({ useIsMobile: () => false }));
vi.mock("@/hooks/use-environments", () => ({ useEnvironments: () => ({ environments: [] }) }));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ authorization.permissions === null || authorization.permissions.has(permission),
+ }),
+}));
vi.mock("next/navigation", () => ({
usePathname: () => "/",
useRouter: () => ({ push: vi.fn() }),
@@ -60,6 +67,7 @@ const noPagination = {
};
beforeEach(() => {
+ authorization.permissions = null;
const attention = session("attention", "Needs review");
const running = session("running", "Implementing inbox");
const child = session("child", "Checking tests", running.id);
@@ -101,6 +109,16 @@ describe("SessionSidebar", () => {
expect(screen.getByRole("link", { name: "Analytics" })).toHaveAttribute("href", "/analytics");
});
+ it("hides application destinations without their canonical read permission", () => {
+ authorization.permissions = new Set(["automations.read"]);
+
+ render( );
+
+ expect(screen.getByRole("link", { name: "Automations" })).toBeInTheDocument();
+ expect(screen.queryByRole("link", { name: "Analytics" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /New session/ })).not.toBeInTheDocument();
+ });
+
it("renders server-classified sections and nested descendants", () => {
render( );
diff --git a/packages/web/src/components/session-sidebar.tsx b/packages/web/src/components/session-sidebar.tsx
index a86fd705a..1394a1adc 100644
--- a/packages/web/src/components/session-sidebar.tsx
+++ b/packages/web/src/components/session-sidebar.tsx
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { useEnvironments } from "@/hooks/use-environments";
import { SessionWithChildren } from "@/components/session-with-children";
import { UserMenu } from "@/components/sidebar-user-menu";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
export type { SessionItem } from "@/hooks/use-sidebar-sessions";
@@ -72,6 +73,7 @@ export function SessionSidebar({
const pathname = usePathname();
const router = useRouter();
const isMobile = useIsMobile();
+ const { hasPermission } = useCurrentUserAuthorization();
const currentSessionId = pathname?.startsWith("/session/") ? pathname.split("/")[2] : null;
@@ -202,7 +204,7 @@ export function SessionSidebar({
-
+ {hasPermission("sessions.create") &&
}
- {PRIMARY_APP_DESTINATIONS.map(({ href, label, icon: Icon }) => (
+ {PRIMARY_APP_DESTINATIONS.filter((destination) =>
+ hasPermission(destination.requiredPermission)
+ ).map(({ href, label, icon: Icon }) => (
({
+const { toastMock, authorizationMock } = vi.hoisted(() => ({
toastMock: {
success: vi.fn(),
error: vi.fn(),
},
+ authorizationMock: { permissions: null as Set
| null },
}));
vi.mock("sonner", () => ({
toast: toastMock,
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ authorizationMock.permissions === null || authorizationMock.permissions.has(permission),
+ }),
+}));
+
vi.mock("next/link", () => ({
default: ({ children, href, ...props }: React.ComponentProps<"a">) => (
@@ -143,9 +151,20 @@ afterEach(async () => {
vi.restoreAllMocks();
toastMock.success.mockReset();
toastMock.error.mockReset();
+ authorizationMock.permissions = null;
});
describe("DataControlsSettings — unarchive flow", () => {
+ it("keeps archived sessions readable without exposing unarchive to read-only roles", async () => {
+ authorizationMock.permissions = new Set(["sessions.read"]);
+ installFetch({ archivedSessions: [createArchivedSession(1)] });
+
+ renderComponent();
+
+ expect(await screen.findByText("Session 1")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Unarchive" })).not.toBeInTheDocument();
+ });
+
it("removes the row when the unarchive request succeeds", async () => {
installFetch({
archivedSessions: [createArchivedSession(1)],
diff --git a/packages/web/src/components/settings/data-controls-settings.tsx b/packages/web/src/components/settings/data-controls-settings.tsx
index 357957ecc..f8eab3002 100644
--- a/packages/web/src/components/settings/data-controls-settings.tsx
+++ b/packages/web/src/components/settings/data-controls-settings.tsx
@@ -17,6 +17,8 @@ import {
} from "@/lib/session-list";
import { formatRelativeTime } from "@/lib/time";
import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
+import { canUseSettingsCapability } from "./settings-registry";
const PAGE_SIZE = 20;
const ARCHIVED_SESSIONS_KEY = buildSessionsPageKey({
@@ -26,6 +28,12 @@ const ARCHIVED_SESSIONS_KEY = buildSessionsPageKey({
});
export function DataControlsSettings() {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canUnarchive = canUseSettingsCapability(
+ "data-controls",
+ "unarchiveSessions",
+ hasPermission
+ );
const [extraSessions, setExtraSessions] = useState([]);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
@@ -126,6 +134,7 @@ export function DataControlsSettings() {
))}
@@ -150,9 +159,11 @@ export function DataControlsSettings() {
function ArchivedSessionRow({
session,
+ canUnarchive,
onUnarchive,
}: {
session: SessionListItem;
+ canUnarchive: boolean;
onUnarchive: (id: string) => void;
}) {
const repoInfo = formatRepoLabel(session.repoOwner, session.repoName);
@@ -169,14 +180,16 @@ function ArchivedSessionRow({
{repoInfo}
- onUnarchive(session.id)}
- className="flex-shrink-0 opacity-0 group-hover:opacity-100"
- >
- Unarchive
-
+ {canUnarchive && (
+ onUnarchive(session.id)}
+ className="flex-shrink-0 opacity-0 group-hover:opacity-100"
+ >
+ Unarchive
+
+ )}
);
}
diff --git a/packages/web/src/components/settings/settings-registry.test.ts b/packages/web/src/components/settings/settings-registry.test.ts
index df2d9bc0f..7992439a2 100644
--- a/packages/web/src/components/settings/settings-registry.test.ts
+++ b/packages/web/src/components/settings/settings-registry.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
SETTINGS_GROUPS,
+ canUseSettingsCapability,
canViewSettingsCategory,
getSettingsPanel,
type SettingsCategory,
@@ -40,4 +41,28 @@ describe("settings registry", () => {
expect(canViewSettingsCategory(category, () => false)).toBe(true);
}
});
+
+ it("requires repository visibility alongside image-build read access", () => {
+ expect(
+ canViewSettingsCategory("images", (permission) => permission === "image_builds.read")
+ ).toBe(false);
+ expect(
+ canViewSettingsCategory("images", (permission) =>
+ ["image_builds.read", "repositories.read"].includes(permission)
+ )
+ ).toBe(true);
+ });
+
+ it("models Data Controls viewing and unarchive capability separately", () => {
+ const readOnly = (permission: string) => permission === "sessions.read";
+ expect(canViewSettingsCategory("data-controls", readOnly)).toBe(true);
+ expect(canUseSettingsCapability("data-controls", "unarchiveSessions", readOnly)).toBe(false);
+ expect(
+ canUseSettingsCapability(
+ "data-controls",
+ "unarchiveSessions",
+ (permission) => permission === "sessions.lifecycle"
+ )
+ ).toBe(true);
+ });
});
diff --git a/packages/web/src/components/settings/settings-registry.ts b/packages/web/src/components/settings/settings-registry.ts
index b5c77ebca..b9b9222f7 100644
--- a/packages/web/src/components/settings/settings-registry.ts
+++ b/packages/web/src/components/settings/settings-registry.ts
@@ -18,6 +18,7 @@ import { lazy, type ComponentType, type LazyExoticComponent } from "react";
type SettingsPermissionPredicate = PermissionId | { allOf: readonly PermissionId[] };
type SettingsVisibility = { public: true } | { anyOf: readonly SettingsPermissionPredicate[] };
+export type SettingsCapability = "unarchiveSessions";
interface SettingsItemDefinition {
id: string;
@@ -27,6 +28,7 @@ interface SettingsItemDefinition {
icon: ComponentType<{ className?: string }>;
visibility: SettingsVisibility;
panel: LazyExoticComponent;
+ capabilities?: Partial>;
requiresRepoImages?: boolean;
}
@@ -193,7 +195,7 @@ export const SETTINGS_GROUPS = [
keywords: "prebuild containers",
icon: BoxIcon,
requiresRepoImages: true,
- visibility: anyOf("image_builds.read"),
+ visibility: anyOf(allOf("image_builds.read", "repositories.read")),
panel: lazyPanel(() =>
import("./images-settings").then(({ ImagesSettings }) => ImagesSettings)
),
@@ -227,6 +229,7 @@ export const SETTINGS_GROUPS = [
keywords: "archive restore retention",
icon: DataControlsIcon,
visibility: anyOf("sessions.read"),
+ capabilities: { unarchiveSessions: "sessions.lifecycle" },
panel: lazyPanel(() =>
import("./data-controls-settings").then(
({ DataControlsSettings }) => DataControlsSettings
@@ -259,6 +262,21 @@ export function canViewSettingsCategory(
);
}
+/** Evaluates a named panel capability from the same descriptor used by settings navigation. */
+export function canUseSettingsCapability(
+ category: SettingsCategory,
+ capability: SettingsCapability,
+ hasPermission: (permission: PermissionId) => boolean
+): boolean {
+ const item: SettingsItemDefinition = getSettingsItem(category);
+ if (!("capabilities" in item)) return false;
+ const predicate = item.capabilities?.[capability];
+ if (!predicate) return false;
+ return typeof predicate === "string"
+ ? hasPermission(predicate)
+ : predicate.allOf.every(hasPermission);
+}
+
/** Selects the requested visible category, or a category the user is allowed to view. */
export function resolveSettingsCategory(
requested: string | null,
diff --git a/packages/web/src/components/settings/settings-shell.test.tsx b/packages/web/src/components/settings/settings-shell.test.tsx
index b4eecb1ac..76b67b62c 100644
--- a/packages/web/src/components/settings/settings-shell.test.tsx
+++ b/packages/web/src/components/settings/settings-shell.test.tsx
@@ -83,4 +83,15 @@ describe("SettingsShell", () => {
expect(mocks.replace).toHaveBeenCalledWith("/settings?tab=appearance");
expect(screen.queryByText("Integration settings")).not.toBeInTheDocument();
});
+
+ it("canonicalizes an unauthorized settings query to the rendered fallback", () => {
+ mocks.pathname = "/settings";
+ mocks.tab = "secrets";
+ mocks.permissions = [];
+
+ render(Secret settings );
+
+ expect(mocks.replace).toHaveBeenCalledWith("/settings?tab=appearance");
+ expect(screen.queryByText("Secret settings")).not.toBeInTheDocument();
+ });
});
diff --git a/packages/web/src/components/settings/settings-shell.tsx b/packages/web/src/components/settings/settings-shell.tsx
index ef7d397f4..8c0baee08 100644
--- a/packages/web/src/components/settings/settings-shell.tsx
+++ b/packages/web/src/components/settings/settings-shell.tsx
@@ -26,17 +26,17 @@ export function SettingsShell({ children }: { children: React.ReactNode }) {
supportsRepoImages(),
hasPermission
);
- const unauthorizedSubroute =
- pathname.startsWith("/settings/integrations/") && activeCategory !== "integrations";
+ const categoryRedirectRequired =
+ requestedCategory !== null && activeCategory !== requestedCategory;
useEffect(() => setIsHydrated(true), []);
useEffect(() => {
- if (isHydrated && !loading && unauthorizedSubroute) {
+ if (isHydrated && !loading && categoryRedirectRequired) {
router.replace(`/settings?tab=${activeCategory}`);
}
- }, [activeCategory, isHydrated, loading, router, unauthorizedSubroute]);
+ }, [activeCategory, categoryRedirectRequired, isHydrated, loading, router]);
- if (!isHydrated || loading || unauthorizedSubroute) {
+ if (!isHydrated || loading || categoryRedirectRequired) {
return ;
}
diff --git a/packages/web/src/components/settings/skills-settings/skill-details.tsx b/packages/web/src/components/settings/skills-settings/skill-details.tsx
new file mode 100644
index 000000000..b5882df9c
--- /dev/null
+++ b/packages/web/src/components/settings/skills-settings/skill-details.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import type { Skill, SkillAssignment } from "@open-inspect/shared/types/skills";
+import { Button } from "@/components/ui/button";
+
+function assignmentLabel(assignment: SkillAssignment): string {
+ if (assignment.type === "global") return "All sessions";
+ if (assignment.type === "repository") {
+ return `Repository: ${assignment.repoOwner}/${assignment.repoName}`;
+ }
+ return `Environment: ${assignment.environmentName ?? assignment.environmentId}`;
+}
+
+/** Read-only detail surface for users who may inspect, but not manage, shared skills. */
+export function SkillDetails({ skill, onClose }: { skill: Skill; onClose: () => void }) {
+ const supplementalFiles = skill.files.filter(({ path }) => path !== "SKILL.md");
+ const metadataEntries = Object.entries(skill.metadata);
+
+ return (
+
+
+
+
{skill.name}
+
{skill.description}
+
+
+ Close
+
+
+
+
+ Instructions
+
+ {skill.body}
+
+
+
+
+
+
License
+
{skill.license ?? "Not specified"}
+
+
+
Compatibility
+
{skill.compatibility ?? "Not specified"}
+
+
+
+
+ Assignments
+ {skill.assignments.length > 0 ? (
+
+ {skill.assignments.map((assignment) => (
+ {assignmentLabel(assignment)}
+ ))}
+
+ ) : (
+ No assignments
+ )}
+
+
+
+ Files
+ {supplementalFiles.length > 0 ? (
+
+ {supplementalFiles.map((file) => (
+
+
+ {file.path}
+ {file.executable ? " (executable)" : ""}
+
+
+ {file.content}
+
+
+ ))}
+
+ ) : (
+ No supplemental files
+ )}
+
+
+ {metadataEntries.length > 0 && (
+
+ Metadata
+
+ {metadataEntries.map(([key, value]) => (
+
+
{key}
+ {value}
+
+ ))}
+
+
+ )}
+
+ {skill.source && (
+
+ Import source
+
+ {skill.source.repoOwner}/{skill.source.repoName} at {skill.source.commitSha}
+ {skill.source.subdirectory ? ` / ${skill.source.subdirectory}` : ""}
+
+
+ )}
+
+
+ Revision {skill.revisionNumber} by{" "}
+ {skill.revisionAuthorDisplayName ?? skill.revisionCreatedBy}
+ {" · "}SHA-256 {skill.revisionSha256}
+
+
+ );
+}
diff --git a/packages/web/src/components/settings/skills-settings/skills-catalog.test.tsx b/packages/web/src/components/settings/skills-settings/skills-catalog.test.tsx
index e40145c8c..1a037298a 100644
--- a/packages/web/src/components/settings/skills-settings/skills-catalog.test.tsx
+++ b/packages/web/src/components/settings/skills-settings/skills-catalog.test.tsx
@@ -10,15 +10,16 @@ import { SkillsCatalog } from "./skills-catalog";
expect.extend(matchers);
-const { useSkillCatalogPageMock } = vi.hoisted(() => ({
+const { useSkillCatalogPageMock, useSkillMock } = vi.hoisted(() => ({
useSkillCatalogPageMock: vi.fn(),
+ useSkillMock: vi.fn(),
}));
vi.mock("@/hooks/use-managed-skills", () => ({
deleteSkill: vi.fn(),
revalidateSkillCatalogPage: vi.fn(),
setSkillEnabled: vi.fn(),
- useSkill: () => ({ skill: undefined, loading: false, error: undefined, mutate: vi.fn() }),
+ useSkill: useSkillMock,
useSkillCatalogPage: useSkillCatalogPageMock,
}));
@@ -63,6 +64,12 @@ beforeEach(() => {
error: undefined,
}
);
+ useSkillMock.mockReturnValue({
+ skill: undefined,
+ loading: false,
+ error: undefined,
+ mutate: vi.fn(),
+ });
});
afterEach(cleanup);
@@ -111,6 +118,51 @@ describe("SkillsCatalog", () => {
expect(screen.getByText("· Created by user-2")).toBeInTheDocument();
});
+ it("opens a complete read-only detail surface for users without manage permission", async () => {
+ const summary = skill("1", "first-skill");
+ useSkillMock.mockReturnValue({
+ skill: {
+ ...summary,
+ body: "## Workflow\nRun the checks.",
+ license: "MIT",
+ compatibility: "Open Inspect",
+ metadata: { owner: "platform" },
+ assignments: [
+ { id: "assignment-1", type: "repository", repoOwner: "acme", repoName: "api" },
+ ],
+ files: [
+ {
+ path: "SKILL.md",
+ content: "generated",
+ sha256: "b".repeat(64),
+ executable: false,
+ sizeBytes: 9,
+ },
+ {
+ path: "scripts/check.sh",
+ content: "npm test",
+ sha256: "c".repeat(64),
+ executable: true,
+ sizeBytes: 8,
+ },
+ ],
+ },
+ loading: false,
+ error: undefined,
+ mutate: vi.fn(),
+ });
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole("button", { name: /first-skill/i }));
+
+ expect(screen.getByText("Run the checks.", { exact: false })).toBeInTheDocument();
+ expect(screen.getByText("Repository: acme/api")).toBeInTheDocument();
+ expect(screen.getByText("scripts/check.sh (executable)")).toBeInTheDocument();
+ expect(screen.getByText(/Revision 1 by User One/)).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /Save new revision/i })).not.toBeInTheDocument();
+ });
+
it.each([
[
"empty",
diff --git a/packages/web/src/components/settings/skills-settings/skills-catalog.tsx b/packages/web/src/components/settings/skills-settings/skills-catalog.tsx
index bd5a24f3b..483c6aead 100644
--- a/packages/web/src/components/settings/skills-settings/skills-catalog.tsx
+++ b/packages/web/src/components/settings/skills-settings/skills-catalog.tsx
@@ -14,6 +14,7 @@ import { Switch } from "@/components/ui/switch";
import { PlusIcon, SparkleIcon } from "@/components/ui/icons";
import { SkillEditor } from "./skill-editor";
import { SkillImport } from "./skill-import";
+import { SkillDetails } from "./skill-details";
import { errorMessage } from "./utils";
/**
@@ -94,7 +95,7 @@ export function SkillsCatalog({ canManage }: { canManage: boolean }) {
return Failed to load this managed skill.
;
if (loadingSkill || !skill)
return Loading skill...
;
- return (
+ return canManage ? (
+ ) : (
+ setSelectedId(null)} />
);
}
@@ -147,8 +150,7 @@ export function SkillsCatalog({ canManage }: { canManage: boolean }) {
canManage && setSelectedId(item.id)}
- disabled={!canManage}
+ onClick={() => setSelectedId(item.id)}
className="min-w-0 flex-1 text-left"
>
diff --git a/packages/web/src/components/settings/workspace-settings.test.tsx b/packages/web/src/components/settings/workspace-settings.test.tsx
index d442a7712..06b77e010 100644
--- a/packages/web/src/components/settings/workspace-settings.test.tsx
+++ b/packages/web/src/components/settings/workspace-settings.test.tsx
@@ -126,4 +126,60 @@ describe("WorkspaceSettings", () => {
expect(updateMember).toHaveBeenCalledWith(member, { kind: "status", suspended: false })
);
});
+
+ it("disables a member's role and status controls while their update is pending", async () => {
+ let finishUpdate!: () => void;
+ const updateMember = vi.fn(() => new Promise
((resolve) => (finishUpdate = resolve)));
+ const member = {
+ userId: "11111111111111111111111111111111",
+ displayName: "Ada",
+ email: "ada@example.com",
+ suspendedAt: null,
+ role: { id: "role_builtin_member", key: "member" as const, name: "Member" },
+ };
+ vi.mocked(useCurrentUserAuthorization).mockReturnValue({
+ authorization: null,
+ loading: false,
+ error: null,
+ hasPermission: (permission) =>
+ permission === "workspace.members.read" ||
+ permission === "workspace.roles.read" ||
+ permission === "workspace.members.manage",
+ });
+ vi.mocked(useWorkspaceAdministration).mockReturnValue({
+ members: [member],
+ roles: [
+ {
+ id: "role_builtin_member",
+ key: "member",
+ name: "Member",
+ description: null,
+ permissions: [],
+ assignmentCount: 1,
+ },
+ {
+ id: "role_builtin_administrator",
+ key: "administrator",
+ name: "Administrator",
+ description: null,
+ permissions: [],
+ assignmentCount: 0,
+ },
+ ],
+ loading: false,
+ error: undefined,
+ updateMember,
+ });
+
+ render( );
+ fireEvent.change(screen.getByRole("combobox"), {
+ target: { value: "role_builtin_administrator" },
+ });
+
+ await waitFor(() => expect(screen.getByRole("combobox")).toBeDisabled());
+ expect(screen.getByRole("button", { name: "Suspend" })).toBeDisabled();
+
+ finishUpdate();
+ await waitFor(() => expect(screen.getByRole("combobox")).toBeEnabled());
+ });
});
diff --git a/packages/web/src/components/settings/workspace-settings.tsx b/packages/web/src/components/settings/workspace-settings.tsx
index fab4d00a0..e5a012fbc 100644
--- a/packages/web/src/components/settings/workspace-settings.tsx
+++ b/packages/web/src/components/settings/workspace-settings.tsx
@@ -21,6 +21,7 @@ export function WorkspaceSettings() {
readRoles: canReadRoles,
});
const [mutationError, setMutationError] = useState(null);
+ const [pendingMemberIds, setPendingMemberIds] = useState>(() => new Set());
const unsuspendedOwnerCount = members.filter(
(member) => member.role.key === "owner" && member.suspendedAt === null
).length;
@@ -28,12 +29,19 @@ export function WorkspaceSettings() {
if (loading) return Loading workspace access...
;
if (error) return Failed to load workspace access. ;
- async function mutate(action: () => Promise) {
+ async function mutate(memberId: string, action: () => Promise) {
setMutationError(null);
+ setPendingMemberIds((current) => new Set(current).add(memberId));
try {
await action();
} catch (cause) {
setMutationError(cause instanceof Error ? cause.message : "Workspace update failed");
+ } finally {
+ setPendingMemberIds((current) => {
+ const next = new Set(current);
+ next.delete(memberId);
+ return next;
+ });
}
}
@@ -74,8 +82,9 @@ export function WorkspaceSettings() {
- void mutate(() =>
+ void mutate(member.userId, () =>
updateMember(member, { kind: "role", roleId: event.target.value })
)
}
@@ -98,12 +107,13 @@ export function WorkspaceSettings() {
variant="outline"
disabled={
!canManage ||
+ pendingMemberIds.has(member.userId) ||
(member.role.key === "owner" &&
(!canTransfer ||
(member.suspendedAt === null && unsuspendedOwnerCount === 1)))
}
onClick={() =>
- void mutate(() =>
+ void mutate(member.userId, () =>
updateMember(member, {
kind: "status",
suspended: member.suspendedAt === null,
diff --git a/packages/web/src/hooks/use-workspace-administration.test.tsx b/packages/web/src/hooks/use-workspace-administration.test.tsx
index 44046d1dd..a07cb2641 100644
--- a/packages/web/src/hooks/use-workspace-administration.test.tsx
+++ b/packages/web/src/hooks/use-workspace-administration.test.tsx
@@ -4,11 +4,14 @@ import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { SWRConfig } from "swr";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { useAuthSession } from "@/lib/auth-session";
+import { clearAuthSessionCache, useAuthSession } from "@/lib/auth-session";
import { browserApiFetch } from "@/lib/browser-api-fetch";
import { useWorkspaceAdministration } from "./use-workspace-administration";
-vi.mock("@/lib/auth-session", () => ({ useAuthSession: vi.fn() }));
+vi.mock("@/lib/auth-session", () => ({
+ useAuthSession: vi.fn(),
+ clearAuthSessionCache: vi.fn(),
+}));
vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() }));
const wrapper = ({ children }: { children: ReactNode }) => (
@@ -52,4 +55,22 @@ describe("useWorkspaceAdministration", () => {
expect.objectContaining({ method: "PUT", body: JSON.stringify({ suspended: true }) })
);
});
+
+ it("treats self-suspension as successful and clears the authenticated session cache", async () => {
+ vi.mocked(useAuthSession).mockReturnValue({
+ data: { user: { id: member.userId, name: "Ada", email: "ada@example.com", image: null } },
+ status: "authenticated",
+ });
+ vi.mocked(clearAuthSessionCache).mockResolvedValue(undefined);
+ const { result } = renderHook(
+ () => useWorkspaceAdministration({ readMembers: false, readRoles: false }),
+ { wrapper }
+ );
+
+ await expect(
+ act(() => result.current.updateMember(member, { kind: "status", suspended: true }))
+ ).resolves.toBeUndefined();
+
+ expect(clearAuthSessionCache).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/packages/web/src/hooks/use-workspace-administration.ts b/packages/web/src/hooks/use-workspace-administration.ts
index 6fc155cbd..e193bbc0a 100644
--- a/packages/web/src/hooks/use-workspace-administration.ts
+++ b/packages/web/src/hooks/use-workspace-administration.ts
@@ -4,11 +4,12 @@ import useSWR, { useSWRConfig } from "swr";
import {
roleListResponseSchema,
workspaceMemberListResponseSchema,
+ type EffectiveAuthorization,
type RoleSummary,
type WorkspaceMember,
} from "@open-inspect/shared/rbac";
import { browserApiFetch } from "@/lib/browser-api-fetch";
-import { useAuthSession } from "@/lib/auth-session";
+import { clearAuthSessionCache, useAuthSession } from "@/lib/auth-session";
import { currentUserAuthorizationKey } from "./use-current-user-authorization";
async function fetchMembers(): Promise {
@@ -48,13 +49,64 @@ export function useWorkspaceAdministration(input: { readMembers: boolean; readRo
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`Member update failed (${response.status})`);
- await Promise.all([
- members.mutate(),
- roles.mutate(),
- session?.user?.id
- ? mutate(currentUserAuthorizationKey(session.user.id), undefined, { revalidate: true })
- : Promise.resolve(undefined),
- ]);
+
+ const nextRole =
+ action.kind === "role" ? roles.data?.find(({ id }) => id === action.roleId) : null;
+ try {
+ await members.mutate(
+ (current) =>
+ current?.map((member) => {
+ if (member.userId !== user.userId) return member;
+ if (action.kind === "status") {
+ return { ...member, suspendedAt: action.suspended ? Date.now() : null };
+ }
+ return nextRole ? { ...member, role: roleReference(nextRole) } : member;
+ }),
+ { revalidate: false }
+ );
+
+ if (action.kind === "role" && nextRole && nextRole.id !== user.role.id) {
+ await roles.mutate(
+ (current) =>
+ current?.map((role) => ({
+ ...role,
+ assignmentCount:
+ role.id === user.role.id
+ ? Math.max(0, role.assignmentCount - 1)
+ : role.id === nextRole.id
+ ? role.assignmentCount + 1
+ : role.assignmentCount,
+ })),
+ { revalidate: false }
+ );
+ }
+
+ if (session?.user?.id === user.userId) {
+ const authorizationKey = currentUserAuthorizationKey(user.userId);
+ if (action.kind === "status" && action.suspended) {
+ await Promise.all([
+ clearAuthSessionCache(),
+ mutate(authorizationKey, undefined, { revalidate: false }),
+ ]);
+ } else if (action.kind === "role" && nextRole) {
+ await mutate(
+ authorizationKey,
+ (current) =>
+ current
+ ? {
+ ...current,
+ role: roleReference(nextRole),
+ permissions: nextRole.permissions,
+ }
+ : current,
+ { revalidate: false }
+ );
+ }
+ }
+ } catch {
+ // The server mutation already committed. Cache maintenance must never
+ // turn that success into a retryable-looking mutation failure.
+ }
}
return {
@@ -65,3 +117,7 @@ export function useWorkspaceAdministration(input: { readMembers: boolean; readRo
updateMember,
};
}
+
+function roleReference(role: RoleSummary): WorkspaceMember["role"] {
+ return { id: role.id, key: role.key, name: role.name };
+}
diff --git a/packages/web/src/lib/auth-session.tsx b/packages/web/src/lib/auth-session.tsx
index 75fc51962..8a774dca9 100644
--- a/packages/web/src/lib/auth-session.tsx
+++ b/packages/web/src/lib/auth-session.tsx
@@ -58,6 +58,11 @@ export async function signOut(): Promise {
if (!response.ok) {
throw new Error(`Sign-out failed with status ${response.status}`);
}
+ await clearAuthSessionCache();
+}
+
+/** Immediately reflect a server-side session revocation in the client cache. */
+export async function clearAuthSessionCache(): Promise {
await mutate(BROWSER_AUTH_SESSION_PATH, null, false);
}
From 39583dc41193bc23a8e91a6fa86bd30c59e03095 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Mon, 31 Aug 2026 01:06:45 -0700
Subject: [PATCH 9/9] test: remove stale RBAC route import
---
packages/control-plane/test/integration/rbac-routes.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/control-plane/test/integration/rbac-routes.test.ts b/packages/control-plane/test/integration/rbac-routes.test.ts
index 23ec8b8dd..2130689b7 100644
--- a/packages/control-plane/test/integration/rbac-routes.test.ts
+++ b/packages/control-plane/test/integration/rbac-routes.test.ts
@@ -1,4 +1,4 @@
-import { env, SELF } from "cloudflare:test";
+import { env } from "cloudflare:test";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthorizationService } from "../../src/authorization/service";
import { UserStore } from "../../src/db/user-store";