From e0a0c70559dc6b18b6bdf6c3fc4f849fa48059d2 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Sun, 30 Aug 2026 21:38:28 -0700 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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) => ( - - ))} + {(["configuration", "secrets", "overrides"] as const) + .filter( + (tab) => + (tab === "configuration" && canManage) || + (tab === "secrets" && canManageSecrets) || + (tab === "overrides" && canReadSettings) + ) + .map((tab) => ( + + ))}
{error && {error}} @@ -242,10 +261,12 @@ export function EnvironmentsSettings() { and triggers a rebuild.

- + {canManageRepoSecrets && ( + + )}
+ {canManage && ( + + )}

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 - - + )} + {canManage && ( + + + + + handlePrebuildToggle(environment, checked) + } + disabled={isToggling} + aria-label={`Toggle prebuilt images for ${environment.name}`} + /> + + + Prebuild images + + )} + {canManageImages && ( + + )} )} - - {confirmDeleteId === environment.id ? ( -
- - -
- ) : ( + {(canManage || canManageSecrets || canReadSettings) && ( )} + {canManage && + (confirmDeleteId === environment.id ? ( +
+ + +
+ ) : ( + + ))}
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() { } } /> - + {canManage && ( + + )} ); 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() { )} -
+
- + ); } 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

- + {canManage && ( + + )}

Configure Model Context Protocol servers that are available to agent sessions. @@ -635,7 +643,8 @@ export function McpServersSettings() { -

- handleToggle(server)} - aria-label={server.enabled ? "Disable" : "Enable"} - /> - -
+ {canManage && ( +
+ handleToggle(server)} + aria-label={server.enabled ? "Disable" : "Enable"} + /> + +
+ )}
{/* 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

- - - - - - Subscriptions - {providers.map((provider) => ( - - beginConnection(CONNECTION_STRATEGIES[provider.provider].add()) - } - > - - {provider.subscriptionName} - - ))} - - + {canManage && ( + + + + + + 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" && ( - - )} - {account.status === "disabled" && ( - - )} - - + {canManage && ( +
+ {account.status === "reconnect_required" && ( - - - {account.status !== "reconnect_required" && ( - - beginConnection( - CONNECTION_STRATEGIES[account.provider].reconnect(account) - ) - } - > - Reconnect - - )} - + )} + {account.status === "disabled" && ( + + + + {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" && (

+ 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) => ( + + ))} + + ) : ( + {member.role.name} + )} + + + ))} + +

+ )} + + {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 3f39e3ac9476cab799aa15f264f100020f0f9d4a Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Sun, 30 Aug 2026 21:56:45 -0700 Subject: [PATCH 06/11] feat: gate session and automation UI by permission --- README.md | 8 +- docs/AUTH.md | 206 +++++ docs/GETTING_STARTED.md | 62 +- .../automations/[id]/edit/page.test.tsx | 92 ++ .../(sidebar)/automations/[id]/edit/page.tsx | 18 +- .../(sidebar)/automations/[id]/page.test.tsx | 107 +++ .../(app)/(sidebar)/automations/[id]/page.tsx | 106 ++- .../(sidebar)/automations/new/page.test.tsx | 21 +- .../(app)/(sidebar)/automations/new/page.tsx | 11 +- .../(app)/(sidebar)/automations/page.test.tsx | 29 +- .../app/(app)/(sidebar)/automations/page.tsx | 27 +- .../automations/templates/page.test.tsx | 54 ++ .../(sidebar)/automations/templates/page.tsx | 12 + .../web/src/app/(app)/(sidebar)/page.test.tsx | 20 + packages/web/src/app/(app)/(sidebar)/page.tsx | 16 +- .../app/(app)/(sidebar)/session/[id]/page.tsx | 109 ++- .../web/src/components/action-bar.test.tsx | 14 + packages/web/src/components/action-bar.tsx | 35 +- .../automations/automations-list.test.tsx | 81 +- .../automations/automations-list.tsx | 229 ++--- .../web/src/components/diff-retry-notice.tsx | 26 +- .../src/components/mobile-session-actions.tsx | 31 +- .../components/queued-prompt-stack.test.tsx | 13 + .../src/components/queued-prompt-stack.tsx | 24 +- .../web/src/components/session-actions.ts | 1 + .../src/components/session-changes-panel.tsx | 9 +- .../components/session-details-overlay.tsx | 6 + .../src/components/session-header.test.tsx | 30 + .../web/src/components/session-header.tsx | 18 +- .../src/components/session-list-item.test.tsx | 95 ++ .../web/src/components/session-list-item.tsx | 115 +-- .../components/session-prompt-composer.tsx | 2 + .../components/session-right-sidebar.test.tsx | 39 + .../src/components/session-right-sidebar.tsx | 38 +- .../src/components/session-sidebar.test.tsx | 18 +- .../web/src/components/session-sidebar.tsx | 4 +- .../settings/data-controls-settings.test.tsx | 23 + .../settings/data-controls-settings.tsx | 40 +- .../src/components/sidebar-layout.test.tsx | 22 + .../web/src/components/sidebar-layout.tsx | 9 +- .../components/sidebar/metadata-section.tsx | 4 +- .../src/hooks/use-global-shortcuts.test.tsx | 37 +- .../web/src/hooks/use-global-shortcuts.ts | 6 +- packages/web/src/hooks/use-sandbox-access.ts | 9 +- .../web/src/hooks/use-session-socket.test.tsx | 22 + packages/web/src/hooks/use-session-socket.ts | 24 +- .../src/hooks/use-session-transport.test.tsx | 39 + .../web/src/hooks/use-session-transport.ts | 33 +- .../src/lib/automation-authorization.test.ts | 45 + .../web/src/lib/automation-authorization.ts | 17 + .../docs/internal/2026-08-28-rbac-design.md | 815 ++++++++++++++++++ .../docs/internal/2026-08-28-rbac-research.md | 386 +++++++++ .../2026-08-30-session-access-research.md | 407 +++++++++ ...space-wide-session-authorization-design.md | 193 +++++ 54 files changed, 3487 insertions(+), 370 deletions(-) create mode 100644 docs/AUTH.md create mode 100644 packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx create mode 100644 packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx create mode 100644 packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx create mode 100644 packages/web/src/components/session-list-item.test.tsx create mode 100644 packages/web/src/lib/automation-authorization.test.ts create mode 100644 packages/web/src/lib/automation-authorization.ts create mode 100644 public/docs/internal/2026-08-28-rbac-design.md create mode 100644 public/docs/internal/2026-08-28-rbac-research.md create mode 100644 public/docs/internal/2026-08-30-session-access-research.md create mode 100644 public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md diff --git a/README.md b/README.md index 304c79a66..58c915e48 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ The system uses a shared GitHub App installation for git operations (clone, fetc control plane mints short-lived installation tokens server-side and brokers them to sandboxes through the git credential helper on demand. This means: -- **All users share the same GitHub App credentials** - The GitHub App must be installed on your - organization's repositories, and any user of the system can access any repo the App has access to +- **Authorized users share the same GitHub App credentials** - The GitHub App must be installed on + your organization's repositories, and active users whose role permits repository use can access + any repo the App has access to - **No per-user repository access validation** - The system does not verify that a user has permission to access a specific repository before creating a session - **GitHub users' OAuth tokens are used for PR creation** - For GitHub logins, PRs are created using @@ -70,6 +71,9 @@ built for internal use where all employees are trusted and have access to compan 4. **Use GitHub's repository selection** - When installing the App, select specific repositories rather than "All repositories" +See [Authentication and Authorization](docs/AUTH.md) for workspace roles, session access, automation +ownership, bots, and member suspension. + ## Architecture ``` diff --git a/docs/AUTH.md b/docs/AUTH.md new file mode 100644 index 000000000..e8d9f4fc8 --- /dev/null +++ b/docs/AUTH.md @@ -0,0 +1,206 @@ +# Authentication and Authorization + +Open-Inspect uses authentication to establish who you are and workspace authorization to decide what +you can do. This guide explains the behavior users and workspace administrators will see. + +> **Important:** Open-Inspect is designed for a single trusted organization. A deployment is one +> workspace, and the source-control App installation defines the repositories available to that +> workspace. Roles control which Open-Inspect features a person can use; they are not per-repository +> access lists. + +--- + +## Signing In + +A deployment can offer GitHub sign-in, Google sign-in, or both. The sign-in page shows only the +providers configured by the deployment operator. + +Signing in has two stages: + +1. Your identity provider verifies your identity and email address. +2. The deployment's admission rules determine whether you may join the workspace. + +Depending on the deployment configuration, admission can be limited by: + +- GitHub username +- Verified email address +- Verified email domain +- Active membership in an allowed GitHub organization + +These rules are checked when you sign in. Removing someone from an allowlist or GitHub organization +does not end an existing browser session; an Administrator or Owner can suspend the member when +access must be revoked immediately. + +Authentication does not make someone an Owner or Administrator. Every admitted user has exactly one +workspace role, and new users receive the Member role by default. + +## Workspace Roles + +Open-Inspect includes four built-in roles. + +| Capability | Owner | Administrator | Member | Viewer | +| ------------------------------------------------- | :---: | :-----------: | :----: | :----: | +| View repositories and environments | Yes | Yes | Yes | Yes | +| Use repositories and environments in sessions | Yes | Yes | Yes | No | +| Manage shared settings, integrations, and secrets | Yes | Yes | No | No | +| Create sessions | Yes | Yes | Yes | No | +| View every session | Yes | Yes | Yes | Yes | +| Collaborate in and manage sessions | Yes | Yes | Yes | No | +| View automations | Yes | Yes | Yes | Yes | +| Create automations | Yes | Yes | Yes | No | +| Manage and trigger own automations | Yes | Yes | Yes | No | +| Manage and trigger any automation | Yes | Yes | No | No | +| View and manage workspace members | Yes | Yes | No | No | +| Transfer workspace ownership | Yes | No | No | No | +| View analytics | Yes | Yes | Yes | Yes | +| View provider accounts | Yes | Yes | Yes | No | +| View image-build history | Yes | Yes | Yes | Yes | +| Manage personal skill profiles | Yes | Yes | Yes | No | + +### Owner + +Owners have full access to the workspace. Only Owners can grant or remove the Owner role or suspend +and restore another Owner. Open-Inspect also prevents the final active Owner from being suspended or +demoted, so the workspace cannot accidentally lose all ownership. + +### Administrator + +Administrators can operate the workspace day to day. They can manage members, sessions, automations, +repositories, environments, provider accounts, integrations, and secrets. They cannot transfer +ownership, change who holds the Owner role, or suspend and restore an Owner. + +### Member + +Members can create and use sessions, collaborate in existing sessions, use shared repositories and +environments, and create automations. They can manage and manually trigger automations they own but +cannot modify another person's automation or administer shared configuration. They can view +workspace analytics. + +### Viewer + +Viewers have read-only access to shared workspace resources. They can inspect sessions, automations, +analytics, repositories, environments, skills, and MCP servers. They cannot create or prompt +sessions, access sandboxes, manage personal skill profiles, trigger automations, or change shared +configuration. + +## How Session Access Works + +Sessions are workspace resources rather than private resources owned by their creator. + +- Anyone with session read access can view every session in the workspace. +- Anyone with collaboration access can prompt and contribute to every session. +- Anyone with lifecycle access can stop, retry, archive, unarchive, and otherwise manage every + session. +- Anyone with sandbox access can use supported sandbox tools for every session. +- Anyone with delete access can delete every session. + +The creator shown on a session records attribution; it is not an access list. Likewise, participant +labels identify who contributed to a session but do not grant or remove workspace permissions. The +**Mine** filter is a convenience for finding sessions you created, not a security boundary. + +Creating a session also requires permission to use its selected repository or environment. A role +may therefore be able to view an existing session without being allowed to create a new one. + +New HTTP requests reflect role changes and suspension immediately. Live browser connections to a +session are rechecked at least every five minutes, so a connection may remain open for up to five +minutes after access changes. Recreating the session is not required. + +## How Automation Access Works + +Automation definitions and run history are visible workspace-wide to roles with automation read +access. Creating, changing, and manually triggering automations use ownership rules. + +- Members can manage and manually trigger automations they own. +- Administrators and Owners can manage and manually trigger any automation. +- Viewers can inspect automations but cannot create, change, or run them. + +Automation ownership follows the signed-in account that created it, not a display name or external +provider username. + +### Scheduled and Event Runs + +Scheduled and event-driven runs execute under the automation owner's authority. At run time, the +owner must still be active and allowed to create sessions and use every selected repository or +environment. If those permissions have been removed, the run does not start. + +### Manual Runs + +A manual run executes under the authority of the person who clicked **Run**, even when an +Administrator or Owner triggers someone else's automation. The requester must be allowed both to +trigger that automation and to create the resulting session with its selected resources. Their +identity and linked source-control credentials are used for that run. + +See [Automations](AUTOMATIONS.md) for trigger setup and run behavior. + +## Bots and Integrations + +Slack, GitHub, and Linear integrations act on behalf of a workspace user when they handle a user +request. Their effective access is limited by both: + +- The acting user's current role +- The integration's fixed set of allowed operations + +This means an integration cannot bypass a suspended user or perform workspace administration simply +because the acting user is an Owner. Calls that do not identify an acting user are denied unless a +specific integration route explicitly permits that operation. + +Some integrations also apply their own ingress rules. For example, the GitHub integration may +require an allowed trigger user or sufficient repository collaborator access before it sends a +request to Open-Inspect. + +## Suspension + +Suspending a member disables their workspace access without deleting their account or historical +attribution. + +After suspension: + +- New browser and bot operations are denied. +- Existing browser sign-in sessions are invalidated. +- Live browser session connections close within five minutes. +- Scheduled and event-driven automations owned by the member no longer pass run authorization. +- Existing session history and authorship remain intact. + +Suspension does not automatically stop a sandbox that is already executing. An Administrator or +Owner can manage that session separately. + +## Repository and Credential Boundaries + +Open-Inspect uses a shared source-control App installation for clone, fetch, and push operations. +The App should be installed only on repositories intended for the workspace. + +A user's role determines whether they may read or use workspace repositories, but Open-Inspect does +not compare that role with the user's personal GitHub access for each repository. Linked GitHub +credentials can be used for actions such as attributed pull-request creation; when no suitable user +credential is available, supported operations may use the shared App identity. + +Secrets and provider credentials are not made visible through role-based read access. Administrative +permissions control who can configure them, and saved secret values are not returned to the browser. +See [Secrets Management](SECRETS.md) for details. + +## Workspace Administration + +Owners and Administrators can manage members from **Settings > Workspace access**. Depending on +their own role, they can: + +- Review workspace members and assigned roles +- Change a member's role +- Suspend or restore a member + +Only an Owner can assign or remove the Owner role or suspend and restore another Owner. The final +active Owner cannot be suspended or demoted. + +### Initial Owner Setup + +The first person who signs in receives the default Member role and is not promoted to Owner +automatically. On a new deployment, the intended Owner must sign in once, after which a deployment +operator runs the Owner bootstrap command using that person's Open-Inspect user ID. See +[Getting Started](GETTING_STARTED.md#step-7a-bootstrap-the-workspace-owner) for the deployment +steps. + +## Related Guides + +- [Getting Started](GETTING_STARTED.md) +- [Automations](AUTOMATIONS.md) +- [Secrets Management](SECRETS.md) +- [How Open-Inspect Works](HOW_IT_WORKS.md) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index d9c2402dc..9600c52d6 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -302,10 +302,11 @@ GitHub OAuth sign-in, but its client pair is optional when Google is the only si > **Keep "User-to-server token expiration" active** (GitHub App → **Optional Features**; it is > the default for newly created Apps, but activate it if yours predates that default). Expiring > user tokens are what make GitHub return a **refresh token** at sign-in, and Open-Inspect stores - > that per-user credential so sessions clone, commit, and push **as the signed-in user**. With - > expiration deactivated — or on an **OAuth App**, which never issues a refresh token — no - > per-user credential is captured and sessions fall back to the shared GitHub App **bot** - > identity for repository access. + > that per-user credential for attributed GitHub operations such as pull-request creation. Clone, + > fetch, and push authentication still use the shared GitHub App installation. With expiration + > deactivated — or on an **OAuth App**, which never issues a refresh token — no per-user + > credential is captured, so supported attributed operations fall back to the shared GitHub App + > **bot** identity. 5. Set **Repository permissions**: - Actions: **Read-only** _(required for GitHub workflow-run automations)_ @@ -651,10 +652,9 @@ configurations because they authorize repository operations; they do not enable ### Enable Google Login (Optional) -Google login lets non-developer users (PMs, support agents) sign in without a GitHub account. They -get the same flat access as everyone else; git operations still use the shared GitHub App, and their -PRs fall back to the App bot (no personal GitHub attribution unless the same verified email is also -a linked GitHub identity). +Google login lets non-developer users (PMs, support agents) sign in without a GitHub account. Git +operations still use the shared GitHub App, and their PRs fall back to the App bot (no personal +GitHub attribution unless the same verified email is also a linked GitHub identity). 1. In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), create an **OAuth client ID** of type **Web application**. @@ -726,6 +726,52 @@ Terraform will update the workers with the required bindings. --- +## Step 7a: Bootstrap the Workspace Owner + +Owner assignment is an explicit operator action. After both deployment phases complete: + +1. Have the intended Owner sign in to the deployed web application once. This creates their + canonical user and default role assignment. +2. While signed in, open `/api/auth/get-session` on the web application origin and record the + 32-character lowercase hexadecimal `user.id`. The bootstrap command accepts this canonical ID, + never an email address. +3. Obtain the D1 database name with `terraform output -raw d1_database_name` from + `terraform/environments/production`. +4. From the repository root, run the remote dry run (the default): + +```bash +npm run rbac:bootstrap-owner -- \ + --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \ + --user "" +``` + +5. Confirm the preflight result is `ready` (or `no-op` when the target is already the current + unsuspended Owner), then execute the same command with `--execute`: + +```bash +npm run rbac:bootstrap-owner -- \ + --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \ + --user "" \ + --execute +``` + +The command uses Wrangler credentials (`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, or +`wrangler login`) and targets remote D1. It refuses a suspended/missing user, a missing or ambiguous +assignment, or another unsuspended Owner. There is no force option. Execution is one atomic Wrangler +SQL file: it writes one redacted `workspace.owner_bootstrapped` service audit event and replaces the +target's assignment. A no-op writes nothing. + +6. Verify the control-plane health response contains `"rbac":{"ownerAssignment":"present"}`: + +```bash +curl "$(terraform -chdir=terraform/environments/production output -raw control_plane_url)/health" +``` + +This health value reports current state: `present` means at least one Owner assignment belongs to an +unsuspended user. + +--- + ## Step 7b: Complete Slack Setup (If Using Slack) Now that the Slack bot worker is deployed, configure the agent experience, App Home, and event diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx new file mode 100644 index 000000000..cf08e8764 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +/// + +import { Suspense } from "react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import EditAutomationPage from "./page"; + +expect.extend(matchers); + +const CURRENT_USER_ID = "11111111111111111111111111111111"; +let permissions: string[] = []; +const replace = vi.fn(); + +const automation = { + id: "auto-1", + name: "Nightly review", + instructions: "Review the code", + triggerType: "schedule" as const, + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: null, + enabled: true, + nextRunAt: null, + consecutiveFailures: 0, + createdBy: CURRENT_USER_ID, + userId: "22222222222222222222222222222222", + createdAt: 1, + updatedAt: 1, + deletedAt: null, + eventType: null, + triggerConfig: null, + repositories: [], + environmentIds: [], + providerSelections: {}, +}; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace }), +})); +vi.mock("@/components/sidebar-layout", () => ({ + CollapsedSidebarControls: () => null, + useSidebarContext: () => ({ isOpen: true }), +})); +vi.mock("@/hooks/use-automations", () => ({ + useAutomation: () => ({ automation, loading: false }), +})); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + authorization: { userId: CURRENT_USER_ID, permissions }, + loading: false, + }), +})); +vi.mock("@/components/automations/automation-form", () => ({ + AutomationForm: () =>
Automation edit form
, +})); + +async function renderPage() { + await act(async () => { + render( + + + + ); + }); +} + +beforeEach(() => { + permissions = []; + replace.mockReset(); +}); +afterEach(cleanup); + +describe("EditAutomationPage authorization", () => { + it("redirects an unauthorized own-scoped deep link without rendering the form", async () => { + permissions = ["automations.manage.own"]; + await renderPage(); + + await waitFor(() => expect(replace).toHaveBeenCalledWith("/automations/auto-1")); + expect(screen.queryByText("Automation edit form")).not.toBeInTheDocument(); + }); + + it("renders the form with automations.manage.any", async () => { + permissions = ["automations.manage.any"]; + await renderPage(); + + expect(await screen.findByText("Automation edit form")).toBeInTheDocument(); + expect(replace).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx index 2224ac53f..0443a31ae 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, use } from "react"; +import { useEffect, useState, use } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; @@ -12,14 +12,26 @@ import { import { ErrorBanner } from "@/components/ui/error-banner"; import { BackIcon } from "@/components/ui/icons"; import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { canAccessAutomation } from "@/lib/automation-authorization"; export default function EditAutomationPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const { isOpen } = useSidebarContext(); const router = useRouter(); const { automation, loading } = useAutomation(id); + const { authorization, loading: authorizationLoading } = useCurrentUserAuthorization(); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); + const canManage = automation + ? canAccessAutomation("automations.manage", authorization, automation) + : false; + + useEffect(() => { + if (!loading && !authorizationLoading && automation && !canManage) { + router.replace(`/automations/${id}`); + } + }, [automation, authorizationLoading, canManage, id, loading, router]); const handleSubmit = async (values: AutomationFormValues) => { setSubmitting(true); @@ -45,7 +57,7 @@ export default function EditAutomationPage({ params }: { params: Promise<{ id: s } }; - if (loading) { + if (loading || authorizationLoading) { return (
@@ -66,6 +78,8 @@ export default function EditAutomationPage({ params }: { params: Promise<{ id: s ); } + if (!canManage) return null; + return (
{!isOpen && ( diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx new file mode 100644 index 000000000..a877f4b02 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +/// + +import { Suspense } from "react"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AutomationDetailPage from "./page"; + +expect.extend(matchers); + +const CURRENT_USER_ID = "11111111111111111111111111111111"; +const OTHER_USER_ID = "22222222222222222222222222222222"; +let permissions: string[] = []; + +const automation = { + id: "auto-1", + name: "Nightly review", + instructions: "Review the code", + triggerType: "schedule" as const, + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: null, + enabled: true, + nextRunAt: null, + consecutiveFailures: 0, + createdBy: CURRENT_USER_ID, + userId: OTHER_USER_ID, + createdAt: 1, + updatedAt: 1, + deletedAt: null, + eventType: null, + triggerConfig: null, + repositories: [], + environmentIds: [], + providerSelections: {}, +}; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock("next/link", () => ({ + default: ({ children, ...props }: React.ComponentProps<"a">) => {children}, +})); +vi.mock("@/components/sidebar-layout", () => ({ + CollapsedSidebarControls: () => null, + useSidebarContext: () => ({ isOpen: true }), +})); +vi.mock("@/hooks/use-automations", () => ({ + useAutomation: () => ({ automation, loading: false, mutate: vi.fn() }), + useAutomationInvocations: () => ({ + invocations: [], + total: 0, + loading: false, + mutate: vi.fn(), + }), +})); +vi.mock("@/hooks/use-environments", () => ({ + useEnvironments: () => ({ environments: [] }), +})); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + authorization: { + userId: CURRENT_USER_ID, + permissions, + }, + }), +})); +vi.mock("@/components/automations/run-history", () => ({ RunHistory: () => null })); + +async function renderPage() { + await act(async () => { + render( + + + + ); + }); +} + +beforeEach(() => { + permissions = []; +}); +afterEach(cleanup); + +describe("AutomationDetailPage authorization", () => { + it("does not treat createdBy provenance as canonical ownership", async () => { + permissions = ["automations.manage.own", "automations.trigger.own"]; + await renderPage(); + await screen.findByRole("heading", { name: "Nightly review" }); + + expect(screen.queryByRole("link", { name: /edit/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Trigger Now" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Pause" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); + }); + + it("shows manage and trigger controls with any-scoped capabilities", async () => { + permissions = ["automations.manage.any", "automations.trigger.any"]; + await renderPage(); + await screen.findByRole("heading", { name: "Nightly review" }); + + expect(screen.getByRole("link", { name: /edit/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Trigger Now" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx index 49bc584ec..bd718936c 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx @@ -17,6 +17,8 @@ import { BackIcon, PencilIcon } from "@/components/ui/icons"; import { formatModelNameLower } from "@/lib/format"; import { formatAutomationTargetsLabel } from "@/lib/repo-label"; import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { canAccessAutomation } from "@/lib/automation-authorization"; const HISTORY_PAGE_SIZE = 20; @@ -25,6 +27,7 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: const { isOpen } = useSidebarContext(); const router = useRouter(); const { automation, loading, mutate } = useAutomation(id); + const { authorization } = useCurrentUserAuthorization(); const { environments } = useEnvironments(); // "Load more" grows the fetch limit rather than paging by offset: the // endpoint returns newest-first, so a larger limit re-fetches the head plus @@ -96,6 +99,9 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: ); } + const canManage = canAccessAutomation("automations.manage", authorization, automation); + const canTrigger = canAccessAutomation("automations.trigger", authorization, automation); + return (
{!isOpen && ( @@ -139,70 +145,76 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id:

- - - - - {automation.enabled ? ( - - ) : ( + {canManage && ( + + + + )} + {canTrigger && ( )} - {confirmDelete ? ( -
+ {canManage && + (automation.enabled ? ( + ) : ( -
- ) : ( - - )} + ))} + {canManage && + (confirmDelete ? ( +
+ + +
+ ) : ( + + ))}
diff --git a/packages/web/src/app/(app)/(sidebar)/automations/new/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/new/page.test.tsx index 6ee14e82b..f237d7dc1 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/new/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/new/page.test.tsx @@ -14,10 +14,19 @@ afterEach(cleanup); // Mutable per-test inputs (vi.mock factories are hoisted, so they close over these). let search = ""; let enabledModelsValue: string[] = [DEFAULT_MODEL, "anthropic/claude-opus-4-8", "openai/gpt-5.5"]; +let canCreate = true; +const replace = vi.fn(); vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(search), - useRouter: () => ({ push: vi.fn() }), + useRouter: () => ({ push: vi.fn(), replace }), +})); + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => permission === "automations.create" && canCreate, + loading: false, + }), })); vi.mock("@/components/sidebar-layout", () => ({ @@ -58,9 +67,19 @@ vi.mock("@/components/ui/combobox", () => ({ beforeEach(() => { search = ""; enabledModelsValue = [DEFAULT_MODEL, "anthropic/claude-opus-4-8", "openai/gpt-5.5"]; + canCreate = true; + replace.mockReset(); }); describe("NewAutomationPage template pre-fill", () => { + it("redirects a direct create link without automations.create", () => { + canCreate = false; + render(); + + expect(replace).toHaveBeenCalledWith("/automations"); + expect(screen.queryByRole("heading", { name: "Create Automation" })).not.toBeInTheDocument(); + }); + it("pre-fills the form from a known template and leaves the repository empty", () => { search = "template=find-bugs"; render(); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/new/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/new/page.tsx index 62917eaf6..3116bdd5a 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/new/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/new/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; import { @@ -14,11 +14,14 @@ import { ErrorBanner } from "@/components/ui/error-banner"; import { BackIcon } from "@/components/ui/icons"; import { browserApiFetch } from "@/lib/browser-api-fetch"; import Link from "next/link"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; function NewAutomationContent() { const { isOpen } = useSidebarContext(); const router = useRouter(); const searchParams = useSearchParams(); + const { hasPermission, loading: authorizationLoading } = useCurrentUserAuthorization(); + const canCreate = hasPermission("automations.create"); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); @@ -29,6 +32,12 @@ function NewAutomationContent() { sentryWebhookUrl?: string; } | null>(null); + useEffect(() => { + if (!authorizationLoading && !canCreate) router.replace("/automations"); + }, [authorizationLoading, canCreate, router]); + + if (authorizationLoading || !canCreate) return null; + // A template id (from the gallery) pre-fills the form. Repository is never // pre-filled, so the repo-required-at-creation invariant is untouched. The // form coerces a template's suggested model against the user's enabled set. diff --git a/packages/web/src/app/(app)/(sidebar)/automations/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/page.test.tsx index 079e8141f..5024d0d60 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/page.test.tsx @@ -8,11 +8,14 @@ import AutomationsPage from "./page"; expect.extend(matchers); -const { mockReplace, mockUseAutomations, mockSearchParamsState } = vi.hoisted(() => ({ - mockReplace: vi.fn(), - mockUseAutomations: vi.fn(), - mockSearchParamsState: { value: new URLSearchParams() }, -})); +const { mockReplace, mockUseAutomations, mockSearchParamsState, mockPermissions } = vi.hoisted( + () => ({ + mockReplace: vi.fn(), + mockUseAutomations: vi.fn(), + mockSearchParamsState: { value: new URLSearchParams() }, + mockPermissions: new Set(), + }) +); vi.mock("next/navigation", () => ({ usePathname: () => "/automations", @@ -37,6 +40,12 @@ vi.mock("@/hooks/use-automations", () => ({ useAutomations: mockUseAutomations, })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => mockPermissions.has(permission), + }), +})); + vi.mock("@/components/automations/automations-list", () => ({ AutomationsList: ({ automations }: { automations: Array<{ name: string }> }) => (
{automations.map((automation) => automation.name).join(", ")}
@@ -58,6 +67,8 @@ describe("AutomationsPage", () => { vi.useFakeTimers(); mockReplace.mockReset(); mockSearchParamsState.value = new URLSearchParams(); + mockPermissions.clear(); + mockPermissions.add("automations.create"); mockUseAutomations.mockReturnValue(defaultHookResult); }); @@ -127,4 +138,12 @@ describe("AutomationsPage", () => { ); expect(mockUseAutomations).toHaveBeenLastCalledWith("weekly"); }); + + it("hides create and template entry points without automations.create", () => { + mockPermissions.clear(); + render(); + + expect(screen.queryByRole("link", { name: "Browse templates" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Create Automation" })).not.toBeInTheDocument(); + }); }); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/page.tsx index cd555b81e..0481a6f96 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/page.tsx @@ -11,6 +11,7 @@ import { ErrorBanner } from "@/components/ui/error-banner"; import { Input } from "@/components/ui/input"; import { PlusIcon, SearchIcon } from "@/components/ui/icons"; import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; const SEARCH_DEBOUNCE_MS = 300; @@ -32,6 +33,8 @@ function AutomationsContent() { const [nameSearch, setNameSearch] = useState(urlNameSearch); const { automations, loading, loadingMore, error, hasMore, loadMore, mutate } = useAutomations(committedNameSearch); + const { hasPermission } = useCurrentUserAuthorization(); + const canCreate = hasPermission("automations.create"); const [actionError, setActionError] = useState(null); @@ -92,17 +95,19 @@ function AutomationsContent() {

Automations

-
- - -
+ {canCreate && ( +
+ + +
+ )}
diff --git a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx new file mode 100644 index 000000000..c99c56086 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +/// + +import { cleanup, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AutomationTemplatesPage from "./page"; + +expect.extend(matchers); + +let canCreate = true; +const replace = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace }), +})); + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => permission === "automations.create" && canCreate, + loading: false, + }), +})); + +vi.mock("@/components/sidebar-layout", () => ({ + CollapsedSidebarControls: () => null, + useSidebarContext: () => ({ isOpen: true }), +})); + +vi.mock("@/components/automations/template-gallery", () => ({ + TemplateGallery: () =>
Template gallery
, +})); + +beforeEach(() => { + canCreate = true; + replace.mockReset(); +}); + +afterEach(cleanup); + +describe("AutomationTemplatesPage", () => { + it("renders templates with automations.create", () => { + render(); + expect(screen.getByRole("heading", { name: "Automation templates" })).toBeInTheDocument(); + }); + + it("redirects a direct template link without automations.create", () => { + canCreate = false; + render(); + + expect(replace).toHaveBeenCalledWith("/automations"); + expect(screen.queryByRole("heading", { name: "Automation templates" })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx index 752cbcb19..56d3936c9 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx @@ -1,12 +1,24 @@ "use client"; import Link from "next/link"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; import { TemplateGallery } from "@/components/automations/template-gallery"; import { BackIcon } from "@/components/ui/icons"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; export default function AutomationTemplatesPage() { const { isOpen } = useSidebarContext(); + const router = useRouter(); + const { hasPermission, loading } = useCurrentUserAuthorization(); + const canCreate = hasPermission("automations.create"); + + useEffect(() => { + if (!loading && !canCreate) router.replace("/automations"); + }, [canCreate, loading, router]); + + if (loading || !canCreate) return null; return (
diff --git a/packages/web/src/app/(app)/(sidebar)/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/page.test.tsx index 3692725b7..b75f742b9 100644 --- a/packages/web/src/app/(app)/(sidebar)/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/page.test.tsx @@ -81,6 +81,7 @@ const mocks = vi.hoisted(() => ({ ignoredProfileSkillIds: [], }, keyboardShortcuts: null as unknown as KeyboardShortcutPreferences, + canCreateSession: true, })); const repo = { @@ -97,6 +98,13 @@ vi.mock("@/lib/auth-session", () => ({ useAuthSession: () => ({ data: { user: { id: "user-1" } }, status: "authenticated" }), })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + permission === "sessions.create" && mocks.canCreateSession, + }), +})); + vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mocks.routerPush }), })); @@ -196,6 +204,7 @@ beforeEach(() => { mocks.providerAccountsValue = []; mocks.providerAccountsLoadingValue = false; mocks.keyboardShortcuts = DEFAULT_KEYBOARD_SHORTCUTS; + mocks.canCreateSession = true; mocks.routerPush.mockReset(); mocks.mutateMock.mockReset(); vi.stubGlobal( @@ -245,6 +254,17 @@ function activeOpenAiAccount(id: string): (typeof mocks.providerAccountsValue)[n } describe("Home", () => { + it("does not render session creation UI without session creation permission", () => { + mocks.canCreateSession = false; + + render(); + + expect(screen.getByText("You don't have permission to create sessions.")).toBeInTheDocument(); + expect(screen.queryByPlaceholderText("What do you want to build?")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /send/i })).not.toBeInTheDocument(); + expect(fetch).not.toHaveBeenCalled(); + }); + it("focuses the prompt when the page loads", () => { render(); diff --git a/packages/web/src/app/(app)/(sidebar)/page.tsx b/packages/web/src/app/(app)/(sidebar)/page.tsx index b8d503d4b..a81071a10 100644 --- a/packages/web/src/app/(app)/(sidebar)/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/page.tsx @@ -57,6 +57,7 @@ import type { import { ProviderAuthControls } from "@/components/provider-auth-controls"; import { useProviderAccounts } from "@/hooks/use-provider-accounts"; import { useWarmDraftSession, type WarmDraftSessionRequest } from "@/hooks/use-warm-draft-session"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; import { buildInteractiveProviderRoutingIdentity, parseStoredProviderSelections, @@ -89,6 +90,8 @@ function skillPreviewTarget( export default function Home() { const { data: session } = useAuthSession(); + const { hasPermission } = useCurrentUserAuthorization(); + const canCreateSession = hasPermission("sessions.create"); const router = useRouter(); const picker = useSessionTargetPicker(); const { sessionTarget, buildRequestFields, isLaunchable } = picker; @@ -184,6 +187,7 @@ export default function Home() { ); const warmRequest: WarmDraftSessionRequest | null = + canCreateSession && session && providerSelectionsHydrated && !providerAccounts.loading && @@ -267,6 +271,7 @@ export default function Home() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if ( + !canCreateSession || submitInFlightRef.current || sessionAttachments.isUploading || !providerSelectionsHydrated || @@ -343,6 +348,7 @@ export default function Home() { return ( void; @@ -477,17 +485,21 @@ function HomeContent({ {/* Welcome text */}

Welcome to {APP_NAME}

- {isAuthenticated ? ( + {isAuthenticated && canCreateSession ? (

Ask a question or describe what you want to build

+ ) : isAuthenticated ? ( +

+ You don't have permission to create sessions. +

) : (

Sign in to start a new session

)}
{/* Input box - only show when authenticated */} - {isAuthenticated && ( + {isAuthenticated && canCreateSession && (
{error && {error}} diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx index 9fa0295de..9edf40da8 100644 --- a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx @@ -63,6 +63,7 @@ import { usePromptInput } from "@/hooks/use-prompt-input"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useSessionSnapshot } from "./session-snapshot-provider"; import { useSessionRename } from "@/hooks/use-session-rename"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; type SessionState = ReturnType["sessionState"]; @@ -71,6 +72,10 @@ const DEFAULT_SESSION_STATUS = "created" as const; export default function SessionPage() { const { shortcuts } = useKeyboardShortcuts(); + const { hasPermission } = useCurrentUserAuthorization(); + const canCollaborate = hasPermission("sessions.collaborate"); + const canManageLifecycle = hasPermission("sessions.lifecycle"); + const canAccessSandbox = hasPermission("sessions.sandbox_access"); const initialSnapshot = useSessionSnapshot(); const sessionId = initialSnapshot.session.id; const { @@ -95,7 +100,10 @@ export default function SessionPage() { sendTyping, reconnect, loadOlderEvents, - } = useSessionSocket(sessionId, initialSnapshot); + } = useSessionSocket(sessionId, initialSnapshot, { + collaborate: canCollaborate, + sandboxAccess: canAccessSandbox, + }); const { profiles, participants: profiledParticipants } = useSessionParticipantProfiles( sessionId, participants, @@ -143,7 +151,7 @@ export default function SessionPage() { reasoningEffort, loadingEnabledModels, sessionState?.status ?? DEFAULT_SESSION_STATUS, - ready, + ready && canCollaborate, shortcuts["send-prompt"] ); const [cancellingPromptIds, setCancellingPromptIds] = useState>(new Set()); @@ -217,7 +225,7 @@ export default function SessionPage() { }, [applyTerminalOpen]); const ttydUrl = sessionState?.ttydUrl; const ttydToken = sessionState?.ttydToken; - const showTerminal = !!(ttydUrl && ttydToken && terminalOpen && !isBelowLg); + const showTerminal = !!(canAccessSandbox && ttydUrl && ttydToken && terminalOpen && !isBelowLg); const toggleDetails = useCallback(() => { setIsDetailsOpen((prev) => !prev); @@ -355,44 +363,48 @@ export default function SessionPage() { promptQueue={promptQueue} cancellingPromptIds={cancellingPromptIds} onRemove={handleRemoveQueuedPrompt} + canRemove={canCollaborate} /> - + {canCollaborate && ( + + )}
); @@ -419,13 +431,17 @@ export default function SessionPage() { primaryRepo, onArchive: handleArchive, onUnarchive: handleUnarchive, + canManageLifecycle, }} optimisticTitle={optimisticTitle} renameSession={renameSession} + canRename={canManageLifecycle} + showConnectionStatus={canCollaborate} + canAccessSandbox={canAccessSandbox} /> {/* Connection error banner */} - {(authError || connectionError) && ( + {canCollaborate && (authError || connectionError) && (

{authError || connectionError}

+ {canManageLifecycle && ( + + )} {mediaCount > 0 && (
@@ -140,11 +143,13 @@ export function ActionBar({
- + {canManageLifecycle && ( + + )} ); } diff --git a/packages/web/src/components/automations/automations-list.test.tsx b/packages/web/src/components/automations/automations-list.test.tsx index 5c20c2bbb..3714bfca7 100644 --- a/packages/web/src/components/automations/automations-list.test.tsx +++ b/packages/web/src/components/automations/automations-list.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /// -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; import type { ComponentProps } from "react"; @@ -26,6 +26,22 @@ vi.mock("@/hooks/use-environments", () => ({ })); const noop = () => {}; +const CURRENT_USER_ID = "11111111111111111111111111111111"; +let permissions = ["automations.create", "automations.manage.own", "automations.trigger.own"]; + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + authorization: { + userId: CURRENT_USER_ID, + permissions, + }, + hasPermission: (permission: string) => permissions.includes(permission), + }), +})); + +beforeEach(() => { + permissions = ["automations.create", "automations.manage.own", "automations.trigger.own"]; +}); function makeAutomation(overrides: Partial = {}): AutomationListItem { return { @@ -41,7 +57,7 @@ function makeAutomation(overrides: Partial = {}): Automation nextRunAt: null, consecutiveFailures: 0, createdBy: "user-1", - userId: "11111111111111111111111111111111", + userId: CURRENT_USER_ID, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, @@ -118,6 +134,50 @@ describe("AutomationsList schedule metadata", () => { }); describe("AutomationsList actions", () => { + const renderListWithActions = (automation: AutomationListItem) => + render( + + ); + + it("uses canonical ownership for own-scoped controls", () => { + render( + + ); + + expect(screen.queryByRole("button", { name: "Pause" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Trigger" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /actions for/i })).not.toBeInTheDocument(); + }); + + it("gates manage and trigger controls independently", () => { + permissions = ["automations.manage.any"]; + renderListWithActions(makeAutomation({ userId: null })); + + expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Trigger" })).not.toBeInTheDocument(); + }); + it("offers row actions from the compact menu", async () => { const onTrigger = vi.fn(); render( @@ -237,6 +297,23 @@ describe("AutomationsList empty state", () => { ); }); + it("hides creation entry points without automations.create", () => { + permissions = []; + render( + + ); + + expect(screen.queryByRole("link", { name: /template/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /create automation/i })).not.toBeInTheDocument(); + }); + it("describes an empty name search without showing creation prompts", () => { render( (null); const { environments } = useEnvironments(); - const automationToDelete = automations.find((automation) => automation.id === confirmDeleteId); + const { authorization, hasPermission } = useCurrentUserAuthorization(); + const canCreate = hasPermission("automations.create"); + const automationToDelete = automations.find( + (automation) => + automation.id === confirmDeleteId && + canAccessAutomation("automations.manage", authorization, automation) + ); if (automations.length === 0) { if (emptyState.kind === "no-search-results") { @@ -112,14 +120,16 @@ export function AutomationsList({

Start from a template, or create one to run tasks on a schedule or in response to events.

-
- - -
+ {canCreate && ( +
+ + +
+ )}
); } @@ -127,105 +137,122 @@ export function AutomationsList({ return ( <>
- {automations.map((automation) => ( -
- {/* Header: Name + badge | Actions */} -
-
- - {automation.name} - - - -
-
- {automation.enabled ? ( - - ) : ( - + {automations.map((automation) => { + const canManage = canAccessAutomation("automations.manage", authorization, automation); + const canTrigger = canAccessAutomation("automations.trigger", authorization, automation); + return ( +
+ {/* Header: Name + badge | Actions */} +
+
+ + {automation.name} + + + +
+
+ {canManage && + (automation.enabled ? ( + + ) : ( + + ))} + {canTrigger && ( + + )} + {canManage && ( + + )} +
+ {(canManage || canTrigger) && ( + + + + + + {canManage && ( + + automation.enabled ? onPause(automation.id) : onResume(automation.id) + } + > + {automation.enabled ? "Pause" : "Resume"} + + )} + {canTrigger && ( + onTrigger(automation.id)}> + + )} + {canManage && ( + setConfirmDeleteId(automation.id)} + > + Delete + + )} + + )} - -
- - - - - - - automation.enabled ? onPause(automation.id) : onResume(automation.id) - } - > - {automation.enabled ? "Pause" : "Resume"} - - onTrigger(automation.id)}> - - setConfirmDeleteId(automation.id)} - > - Delete - - - -
- {/* Metadata: icon-paired items */} -
- - {automation.environmentIds.length > 0 && automation.repositories.length === 0 ? ( - - -
-
- ))} + ); + })}
{message}

- + {canRetry && ( + + )}
{retryError && (

@@ -106,23 +107,27 @@ export function MobileSessionActions({ Copy link - - - - {controls.isArchived ? "Unarchive" : "Archive"} - + {canManageLifecycle && } + {canManageLifecycle && ( + + + {controls.isArchived ? "Unarchive" : "Archive"} + + )}

- + {canManageLifecycle && ( + + )} ); } diff --git a/packages/web/src/components/queued-prompt-stack.test.tsx b/packages/web/src/components/queued-prompt-stack.test.tsx index 95db55c53..67e6ccfab 100644 --- a/packages/web/src/components/queued-prompt-stack.test.tsx +++ b/packages/web/src/components/queued-prompt-stack.test.tsx @@ -11,6 +11,19 @@ expect.extend(matchers); afterEach(cleanup); describe("QueuedPromptStack", () => { + it("shows queued prompts without removal controls in read-only mode", () => { + render( + + ); + + expect(screen.getByText("Review this")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Remove queued prompt/ })).not.toBeInTheDocument(); + }); it("renders only pending prompts in FIFO order", () => { render( ; onRemove: (messageId: string) => void; + canRemove?: boolean; }) { const pendingPrompts = promptQueue.filter((item) => item.status === "pending"); if (pendingPrompts.length === 0) return null; @@ -28,16 +30,18 @@ export function QueuedPromptStack({

{prompt.content}

- + {canRemove && ( + + )} ))} diff --git a/packages/web/src/components/session-actions.ts b/packages/web/src/components/session-actions.ts index 774a757c5..ee7ae2b6a 100644 --- a/packages/web/src/components/session-actions.ts +++ b/packages/web/src/components/session-actions.ts @@ -18,6 +18,7 @@ export interface SessionActionProps { primaryRepo?: { repoOwner: string; repoName: string } | null; onArchive?: () => void | Promise; onUnarchive?: () => void | Promise; + canManageLifecycle?: boolean; } /** One PR a session-level action can open, ready to render as a link. */ diff --git a/packages/web/src/components/session-changes-panel.tsx b/packages/web/src/components/session-changes-panel.tsx index 28e383972..10b3c31dd 100644 --- a/packages/web/src/components/session-changes-panel.tsx +++ b/packages/web/src/components/session-changes-panel.tsx @@ -225,6 +225,7 @@ export function SessionChangesPanel({ onClose, onSelect, mobile = false, + canRetry = true, }: { sessionId: string; state: SessionDiffState; @@ -232,6 +233,7 @@ export function SessionChangesPanel({ onClose: () => void; onSelect: (selection: DiffSelection) => void; mobile?: boolean; + canRetry?: boolean; }) { const panelRef = useRef(null); const fileListId = useId(); @@ -308,7 +310,12 @@ export function SessionChangesPanel({ /> {state.lastError && ( - + )}
diff --git a/packages/web/src/components/session-details-overlay.tsx b/packages/web/src/components/session-details-overlay.tsx index 9487ff9fe..3fd4db949 100644 --- a/packages/web/src/components/session-details-overlay.tsx +++ b/packages/web/src/components/session-details-overlay.tsx @@ -47,6 +47,9 @@ export function SessionDetailsOverlay({ diffLoading, selectedDiff, onOpenDiff, + canAccessSandbox, + canManageLifecycle, + canRetryDiff, }: SessionDetailsOverlayProps) { const [sheetDragY, setSheetDragY] = useState(0); const sheetDragYRef = useRef(0); @@ -175,6 +178,9 @@ export function SessionDetailsOverlay({ diffLoading={diffLoading} selectedDiff={selectedDiff} onOpenDiff={onOpenDiff} + canAccessSandbox={canAccessSandbox} + canManageLifecycle={canManageLifecycle} + canRetryDiff={canRetryDiff} /> ); diff --git a/packages/web/src/components/session-header.test.tsx b/packages/web/src/components/session-header.test.tsx index e215c7923..ec1f40581 100644 --- a/packages/web/src/components/session-header.test.tsx +++ b/packages/web/src/components/session-header.test.tsx @@ -43,6 +43,36 @@ function createSessionState(overrides: Partial = {}): SessionState } describe("SessionHeader", () => { + it("disables lifecycle controls and connection UI for a read-only session", async () => { + render( + ()} + actionsButtonRef={createRef()} + onToggleDetails={vi.fn()} + onToggleDesktopDetails={vi.fn()} + onOpenMobileDetails={vi.fn()} + actions={{ ...actions, canManageLifecycle: false }} + renameSession={vi.fn()} + canRename={false} + showConnectionStatus={false} + /> + ); + + expect(screen.getByRole("button", { name: "Session 1" })).toBeDisabled(); + expect(screen.queryByRole("status", { name: /Connection status/ })).not.toBeInTheDocument(); + + const trigger = screen.getByRole("button", { name: "Session actions" }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false }); + expect(screen.queryByRole("menuitem", { name: "Archive" })).not.toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Copy link" })).toBeInTheDocument(); + }); it("lets desktop users hide and show the session details sidebar", () => { const onToggleDesktopDetails = vi.fn(); const { rerender } = render( diff --git a/packages/web/src/components/session-header.tsx b/packages/web/src/components/session-header.tsx index 5e9784a00..20e7ca698 100644 --- a/packages/web/src/components/session-header.tsx +++ b/packages/web/src/components/session-header.tsx @@ -100,6 +100,9 @@ export type SessionHeaderProps = { actions: SessionActionProps; optimisticTitle?: string; renameSession: (title: string) => Promise; + canRename?: boolean; + showConnectionStatus?: boolean; + canAccessSandbox?: boolean; }; export function SessionHeader({ @@ -119,6 +122,9 @@ export function SessionHeader({ actions, optimisticTitle, renameSession, + canRename = true, + showConnectionStatus = true, + canAccessSandbox = true, }: SessionHeaderProps) { const { isOpen } = useSidebarContext(); const hasFallbackSessionInfo = @@ -138,6 +144,7 @@ export function SessionHeader({ optimisticTitle ?? sessionState?.title ?? fallbackSessionInfo.title ?? repoLabel; const handleStartRename = () => { + if (!canRename) return; setTitle(resolvedTitle); setIsRenaming(true); }; @@ -196,9 +203,10 @@ export function SessionHeader({

@@ -226,10 +234,12 @@ export function SessionHeader({ onOpenMedia={onOpenMobileDetails} />
- + {showConnectionStatus && ( + + )}
diff --git a/packages/web/src/components/session-list-item.test.tsx b/packages/web/src/components/session-list-item.test.tsx new file mode 100644 index 000000000..17de0d41b --- /dev/null +++ b/packages/web/src/components/session-list-item.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +/// + +import { fireEvent, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { beforeEach, expect, it, vi } from "vitest"; +import type { SessionItem } from "@/hooks/use-sidebar-sessions"; +import { SessionListItem } from "./session-list-item"; + +expect.extend(matchers); + +const mocks = vi.hoisted(() => ({ + allowedPermissions: new Set(), +})); + +vi.mock("next/link", () => ({ + default: ({ children, ...props }: React.ComponentProps<"a">) => {children}, +})); + +vi.mock("@/hooks/use-session-rename", () => ({ + useSessionRename: () => ({ optimisticTitle: null, renameSession: vi.fn() }), +})); + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => mocks.allowedPermissions.has(permission), + }), +})); + +beforeEach(() => { + mocks.allowedPermissions = new Set(); +}); + +function session(unread = false): SessionItem { + return { + id: "session-1", + title: "Session one", + repoOwner: null, + repoName: null, + baseBranch: null, + status: "active", + parentSessionId: null, + spawnSource: "user", + environmentId: null, + createdAt: 1, + updatedAt: 2, + readState: unread + ? { latestMessageId: "message-1", unread: true } + : { latestMessageId: null, unread: false }, + }; +} + +function renderItem(unread = false) { + render( + + ); +} + +it("fails closed when sessions.lifecycle is denied", () => { + renderItem(); + + expect(screen.queryByRole("button", { name: "Session actions" })).not.toBeInTheDocument(); +}); + +it("shows rename and archive actions when sessions.lifecycle is allowed", async () => { + mocks.allowedPermissions.add("sessions.lifecycle"); + renderItem(); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Session actions" }), { + button: 0, + ctrlKey: false, + }); + + expect(await screen.findByRole("menuitem", { name: "Rename" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Archive" })).toBeInTheDocument(); +}); + +it("keeps mark-as-read available without sessions.lifecycle", async () => { + renderItem(true); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Session actions" }), { + button: 0, + ctrlKey: false, + }); + + expect(await screen.findByRole("menuitem", { name: "Mark as read" })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "Rename" })).not.toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "Archive" })).not.toBeInTheDocument(); +}); diff --git a/packages/web/src/components/session-list-item.tsx b/packages/web/src/components/session-list-item.tsx index f4bd2d503..6ca964b70 100644 --- a/packages/web/src/components/session-list-item.tsx +++ b/packages/web/src/components/session-list-item.tsx @@ -10,6 +10,7 @@ import { formatRelativeTime } from "@/lib/time"; import { MoreIcon, ArchiveIcon, BranchIcon, BoxIcon } from "@/components/ui/icons"; import { formatSessionRepositoriesLabel } from "@/lib/repo-label"; import { useSessionRename } from "@/hooks/use-session-rename"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; import { DropdownMenu, DropdownMenuContent, @@ -22,6 +23,9 @@ import { buildSessionHref } from "@/lib/session-list"; export const MOBILE_LONG_PRESS_MS = 450; const MOBILE_LONG_PRESS_MOVE_THRESHOLD_PX = 10; +/** + * Displays a session and derives lifecycle controls from the current user's workspace permissions. + */ export function SessionListItem({ session, environmentName, @@ -39,6 +43,8 @@ export function SessionListItem({ onSessionSelect?: () => void; onMarkLatestMessageRead: (sessionId: string) => Promise; }) { + const { hasPermission } = useCurrentUserAuthorization(); + const canManageLifecycle = hasPermission("sessions.lifecycle"); const timestamp = session.updatedAt || session.createdAt; const relativeTime = formatRelativeTime(timestamp); const repoInfo = formatSessionRepositoriesLabel( @@ -73,6 +79,7 @@ export function SessionListItem({ }, [displayTitle, isRenaming]); const handleStartRename = () => { + if (!canManageLifecycle) return; isStartingRenameRef.current = true; setIsActionsOpen(false); setTitle(displayTitle); @@ -96,6 +103,7 @@ export function SessionListItem({ }; const handleStartArchive = () => { + if (!canManageLifecycle) return; setIsActionsOpen(false); setShowArchiveDialog(true); }; @@ -161,11 +169,12 @@ export function SessionListItem({ touchStartRef.current = { x: touch.clientX, y: touch.clientY }; clearLongPressTimer(); longPressTimerRef.current = window.setTimeout(() => { + if (!canManageLifecycle && !session.readState.unread) return; longPressTriggeredRef.current = true; setIsActionsOpen(true); }, MOBILE_LONG_PRESS_MS); }, - [clearLongPressTimer, isMobile] + [canManageLifecycle, clearLongPressTimer, isMobile, session.readState.unread] ); const handleTouchMove = useCallback( @@ -303,57 +312,65 @@ export function SessionListItem({ )} -
- - - - - { - if (isStartingRenameRef.current) { - event.preventDefault(); - isStartingRenameRef.current = false; - } - }} - > - Rename - {session.readState.unread && ( - + + +
+ + + + { + if (isStartingRenameRef.current) { + event.preventDefault(); + isStartingRenameRef.current = false; + } + }} + > + {canManageLifecycle && ( + Rename + )} + {session.readState.unread && ( + + Mark as read + + )} + {canManageLifecycle && ( + + + Archive + + )} + + +

+ )}
- + {canManageLifecycle && ( + + )} ); } diff --git a/packages/web/src/components/session-prompt-composer.tsx b/packages/web/src/components/session-prompt-composer.tsx index 675617be1..50bf53179 100644 --- a/packages/web/src/components/session-prompt-composer.tsx +++ b/packages/web/src/components/session-prompt-composer.tsx @@ -24,6 +24,7 @@ type SessionPromptComposerProps = { primaryRepo?: { repoOwner: string; repoName: string } | null; onArchive: () => void | Promise; onUnarchive: () => void | Promise; + canManageLifecycle?: boolean; }; prompt: { value: string; @@ -105,6 +106,7 @@ export function SessionPromptComposer({ primaryRepo={session.primaryRepo} onArchive={session.onArchive} onUnarchive={session.onUnarchive} + canManageLifecycle={session.canManageLifecycle} />
diff --git a/packages/web/src/components/session-right-sidebar.test.tsx b/packages/web/src/components/session-right-sidebar.test.tsx index 2660b5e1b..5d9244cf3 100644 --- a/packages/web/src/components/session-right-sidebar.test.tsx +++ b/packages/web/src/components/session-right-sidebar.test.tsx @@ -3,12 +3,51 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SessionRightSidebar } from "./session-right-sidebar"; +import type { SessionState } from "@open-inspect/shared/types/server-messages"; vi.mock("swr", () => ({ default: () => ({ data: undefined }) })); afterEach(cleanup); describe("SessionRightSidebar", () => { + it("hides sandbox access controls when the capability is denied", () => { + const sessionState: SessionState = { + id: "session-1", + title: "Viewer session", + repoOwner: "acme", + repoName: "web", + baseBranch: "main", + branchName: "viewer", + status: "active", + sandboxStatus: "ready", + messageCount: 0, + createdAt: 1, + codeServerUrl: "https://code.example", + vncUrl: "https://vnc.example", + ttydUrl: "https://terminal.example", + ttydToken: "secret", + tunnelUrls: { app: "https://app.example" }, + }; + + render( + + ); + + expect(screen.queryByText("Open Editor")).not.toBeInTheDocument(); + expect(screen.queryByText("Open Desktop")).not.toBeInTheDocument(); + expect(screen.queryByText("Terminal")).not.toBeInTheDocument(); + expect(screen.queryByText("Port app")).not.toBeInTheDocument(); + expect(screen.getByText("main")).toBeInTheDocument(); + }); it("keeps its ARIA target mounted when closed", () => { render( void; + canAccessSandbox?: boolean; + canManageLifecycle?: boolean; + canRetryDiff?: boolean; } export type SessionRightSidebarContentProps = SessionRightSidebarProps; @@ -59,6 +62,9 @@ export function SessionRightSidebarContent({ diffLoading, selectedDiff, onOpenDiff, + canAccessSandbox = true, + canManageLifecycle = true, + canRetryDiff = true, }: SessionRightSidebarContentProps) { const tasks = useMemo(() => extractLatestTasks(events), [events]); const warnings = useMemo( @@ -124,11 +130,12 @@ export function SessionRightSidebarContent({ warnings={warnings} parentSessionId={sessionState.parentSessionId} totalCost={sessionState.totalCost} + canManageLifecycle={canManageLifecycle} />
{/* Code Server */} - {sessionState.codeServerUrl && ( + {canAccessSandbox && sessionState.codeServerUrl && (
@@ -182,14 +189,16 @@ export function SessionRightSidebarContent({ )} {/* Tunnel URLs */} - {sessionState.tunnelUrls && Object.keys(sessionState.tunnelUrls).length > 0 && ( -
- -
- )} + {canAccessSandbox && + sessionState.tunnelUrls && + Object.keys(sessionState.tunnelUrls).length > 0 && ( +
+ +
+ )} {/* Tasks */} {tasks.length > 0 && ( @@ -243,6 +252,7 @@ export function SessionRightSidebarContent({ sessionId={sessionId} message={diffView.message ?? ""} variant="inline" + canRetry={canRetryDiff} /> )}
@@ -287,6 +297,9 @@ export function SessionRightSidebar({ diffLoading, selectedDiff, onOpenDiff, + canAccessSandbox, + canManageLifecycle, + canRetryDiff, }: SessionRightSidebarProps) { return ( ); diff --git a/packages/web/src/components/session-sidebar.test.tsx b/packages/web/src/components/session-sidebar.test.tsx index 2d2738a39..58216c974 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: { canCreateSession: true }, })); 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) => + permission === "sessions.create" && authorization.canCreateSession, + }), +})); vi.mock("next/navigation", () => ({ usePathname: () => "/", useRouter: () => ({ push: vi.fn() }), @@ -60,6 +67,7 @@ const noPagination = { }; beforeEach(() => { + authorization.canCreateSession = true; const attention = session("attention", "Needs review"); const running = session("running", "Implementing inbox"); const child = session("child", "Checking tests", running.id); @@ -101,6 +109,14 @@ describe("SessionSidebar", () => { expect(screen.getByRole("link", { name: "Analytics" })).toHaveAttribute("href", "/analytics"); }); + it("hides the new session action without session creation permission", () => { + authorization.canCreateSession = false; + + render(); + + 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..9db6293e9 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"; @@ -69,6 +70,7 @@ export function SessionSidebar({ }: SessionSidebarProps) { const { labels } = useKeyboardShortcuts(); const { data: authSession } = useAuthSession(); + const { hasPermission } = useCurrentUserAuthorization(); const pathname = usePathname(); const router = useRouter(); const isMobile = useIsMobile(); @@ -202,7 +204,7 @@ export function SessionSidebar({
- + {hasPermission("sessions.create") && } ({ + allowedPermissions: new Set(["sessions.lifecycle"]), +})); + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => authorizationMocks.allowedPermissions.has(permission), + }), +})); + expect.extend(matchers); const { toastMock } = vi.hoisted(() => ({ @@ -141,11 +151,24 @@ afterEach(async () => { // Clear SWR's global cache between tests so cache state doesn't leak. await globalMutate(() => true, undefined, { revalidate: false }); vi.restoreAllMocks(); + authorizationMocks.allowedPermissions = new Set(["sessions.lifecycle"]); toastMock.success.mockReset(); toastMock.error.mockReset(); }); describe("DataControlsSettings — unarchive flow", () => { + it("hides unarchive when sessions.lifecycle is denied", async () => { + authorizationMocks.allowedPermissions = new Set(); + 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..3956ecc0b 100644 --- a/packages/web/src/components/settings/data-controls-settings.tsx +++ b/packages/web/src/components/settings/data-controls-settings.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import Link from "next/link"; import useSWR, { mutate } from "swr"; import { toast } from "sonner"; @@ -17,6 +17,7 @@ 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"; const PAGE_SIZE = 20; const ARCHIVED_SESSIONS_KEY = buildSessionsPageKey({ @@ -25,14 +26,26 @@ const ARCHIVED_SESSIONS_KEY = buildSessionsPageKey({ offset: 0, }); +/** + * Lists archived sessions and derives lifecycle actions from the current user's workspace permissions. + */ export function DataControlsSettings() { + const { hasPermission } = useCurrentUserAuthorization(); + const canManageLifecycle = hasPermission("sessions.lifecycle"); + const archivedSessionsKey = ARCHIVED_SESSIONS_KEY; const [extraSessions, setExtraSessions] = useState([]); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(false); const offsetRef = useRef(0); + useEffect(() => { + setExtraSessions([]); + setHasMore(false); + offsetRef.current = 0; + }, [archivedSessionsKey]); + const { data, isLoading: loading } = useSWR( - ARCHIVED_SESSIONS_KEY, + archivedSessionsKey, fetchSessionListPage, { onSuccess: (data) => { @@ -77,7 +90,7 @@ export function DataControlsSettings() { } toast.success("Session unarchived"); await mutate( - ARCHIVED_SESSIONS_KEY, + archivedSessionsKey, (current) => current ? { ...current, sessions: removeSessionFromList(current.sessions, sessionId) } @@ -126,6 +139,7 @@ export function DataControlsSettings() { ))} @@ -150,9 +164,11 @@ export function DataControlsSettings() { function ArchivedSessionRow({ session, + canManageLifecycle, onUnarchive, }: { session: SessionListItem; + canManageLifecycle: boolean; onUnarchive: (id: string) => void; }) { const repoInfo = formatRepoLabel(session.repoOwner, session.repoName); @@ -169,14 +185,16 @@ function ArchivedSessionRow({ {repoInfo}
- + {canManageLifecycle && ( + + )}
); } diff --git a/packages/web/src/components/sidebar-layout.test.tsx b/packages/web/src/components/sidebar-layout.test.tsx index 587fb1e3a..75eb99ccd 100644 --- a/packages/web/src/components/sidebar-layout.test.tsx +++ b/packages/web/src/components/sidebar-layout.test.tsx @@ -11,6 +11,7 @@ expect.extend(matchers); const mocks = vi.hoisted(() => ({ isMobile: false, + canCreateSession: true, sidebar: { isOpen: true, toggle: vi.fn(), @@ -32,10 +33,18 @@ vi.mock("@/hooks/use-sidebar", () => ({ useSidebar: () => mocks.sidebar, })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + permission === "sessions.create" && mocks.canCreateSession, + }), +})); + afterEach(() => { cleanup(); vi.clearAllMocks(); mocks.isMobile = false; + mocks.canCreateSession = true; mocks.sidebar.isOpen = true; }); @@ -63,6 +72,19 @@ describe("CollapsedSidebarControls", () => { fireEvent.click(buttons![2]); expect(push).toHaveBeenCalledWith("/"); }); + + it("hides the new session action without session creation permission", () => { + mocks.canCreateSession = false; + vi.mocked(useRouter).mockReturnValue({ push: vi.fn() } as never); + + render( + + + + ); + + expect(screen.queryByRole("button", { name: /New session/ })).not.toBeInTheDocument(); + }); }); describe("mobile sidebar drag", () => { diff --git a/packages/web/src/components/sidebar-layout.tsx b/packages/web/src/components/sidebar-layout.tsx index 5c6ecfcaf..e7b385fc9 100644 --- a/packages/web/src/components/sidebar-layout.tsx +++ b/packages/web/src/components/sidebar-layout.tsx @@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"; import { SidebarIcon } from "@/components/ui/icons"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useMobileSidebarPull } from "@/hooks/use-mobile-sidebar-pull"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; interface SidebarContextValue { isOpen: boolean; @@ -60,6 +61,7 @@ export function SidebarToggleButton({ label = "Open sidebar" }: { label?: string export function CollapsedSidebarControls() { const actions = useContext(AppShellActionsContext); + const { hasPermission } = useCurrentUserAuthorization(); if (!actions) { throw new Error("CollapsedSidebarControls must be used within a SidebarLayout"); } @@ -68,13 +70,15 @@ export function CollapsedSidebarControls() {
- + {hasPermission("sessions.create") && }
); } export function SidebarLayout({ children }: SidebarLayoutProps) { const router = useRouter(); + const { hasPermission } = useCurrentUserAuthorization(); + const canCreateSession = hasPermission("sessions.create"); const sidebar = useSidebar(); const isMobile = useIsMobile(); const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false); @@ -95,12 +99,13 @@ export function SidebarLayout({ children }: SidebarLayoutProps) { ); const handleNewSession = useCallback(() => { + if (!canCreateSession) return; setIsCommandMenuOpen(false); if (isMobile) { sidebar.close(); } router.push("/"); - }, [isMobile, router, sidebar]); + }, [canCreateSession, isMobile, router, sidebar]); const handleNavigate = useCallback( (href: string) => { diff --git a/packages/web/src/components/sidebar/metadata-section.tsx b/packages/web/src/components/sidebar/metadata-section.tsx index 9b16187a0..4d56aa061 100644 --- a/packages/web/src/components/sidebar/metadata-section.tsx +++ b/packages/web/src/components/sidebar/metadata-section.tsx @@ -52,6 +52,7 @@ interface MetadataSectionProps { warnings?: WarningEvent[]; parentSessionId?: string | null; totalCost?: number; + canManageLifecycle?: boolean; } /** @@ -108,12 +109,13 @@ export function MetadataSection({ warnings = [], parentSessionId, totalCost, + canManageLifecycle = true, }: MetadataSectionProps) { const [copied, setCopied] = useState(false); const isMultiRepo = (repositories?.length ?? 0) > 1; const hasPrArtifact = artifacts.some((a) => a.type === "pr"); - const showSyncButton = Boolean(sessionId) && hasPrArtifact; + const showSyncButton = canManageLifecycle && Boolean(sessionId) && hasPrArtifact; // Sessions can hold several PRs (one open PR per head branch); list them // all, oldest first — creation order matches PR-number order. diff --git a/packages/web/src/hooks/use-global-shortcuts.test.tsx b/packages/web/src/hooks/use-global-shortcuts.test.tsx index 68b8b24ef..3b0507bcc 100644 --- a/packages/web/src/hooks/use-global-shortcuts.test.tsx +++ b/packages/web/src/hooks/use-global-shortcuts.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_KEYBOARD_SHORTCUTS } from "@open-inspect/shared/types/keyboard-shortcuts"; import { useGlobalShortcuts } from "./use-global-shortcuts"; +const mocks = vi.hoisted(() => ({ canCreateSession: true })); + const shortcuts = { ...DEFAULT_KEYBOARD_SHORTCUTS, "open-command-menu": { code: "KeyP", primary: true, alt: false, shift: false }, @@ -16,8 +18,18 @@ vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ useKeyboardShortcuts: () => ({ shortcuts }), })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + permission === "sessions.create" && mocks.canCreateSession, + }), +})); + describe("useGlobalShortcuts", () => { - afterEach(() => vi.restoreAllMocks()); + afterEach(() => { + mocks.canCreateSession = true; + vi.restoreAllMocks(); + }); it("dispatches the configured action and removes its listener", () => { const onOpenCommandMenu = vi.fn(); @@ -46,4 +58,27 @@ describe("useGlobalShortcuts", () => { window.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyP", ctrlKey: true })); expect(onOpenCommandMenu).toHaveBeenCalledOnce(); }); + + it("ignores the new session shortcut without session creation permission", () => { + mocks.canCreateSession = false; + const onNewSession = vi.fn(); + renderHook(() => + useGlobalShortcuts({ + onOpenCommandMenu: vi.fn(), + onNewSession, + onToggleSidebar: vi.fn(), + }) + ); + + const event = new KeyboardEvent("keydown", { + code: "KeyN", + ctrlKey: true, + shiftKey: true, + cancelable: true, + }); + window.dispatchEvent(event); + + expect(onNewSession).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); }); diff --git a/packages/web/src/hooks/use-global-shortcuts.ts b/packages/web/src/hooks/use-global-shortcuts.ts index 33446518c..2f5d41355 100644 --- a/packages/web/src/hooks/use-global-shortcuts.ts +++ b/packages/web/src/hooks/use-global-shortcuts.ts @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { matchGlobalShortcut, shouldIgnoreGlobalShortcutForAction } from "@/lib/keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; interface UseGlobalShortcutsOptions { enabled?: boolean; @@ -18,6 +19,8 @@ export function useGlobalShortcuts({ onToggleSidebar, }: UseGlobalShortcutsOptions) { const { shortcuts } = useKeyboardShortcuts(); + const { hasPermission } = useCurrentUserAuthorization(); + const canCreateSession = hasPermission("sessions.create"); useEffect(() => { if (!enabled) return; @@ -25,6 +28,7 @@ export function useGlobalShortcuts({ const action = matchGlobalShortcut(event, shortcuts); if (!action) return; if (shouldIgnoreGlobalShortcutForAction(event, action)) return; + if (action === "new-session" && !canCreateSession) return; event.preventDefault(); @@ -35,5 +39,5 @@ export function useGlobalShortcuts({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [enabled, onNewSession, onOpenCommandMenu, onToggleSidebar, shortcuts]); + }, [canCreateSession, enabled, onNewSession, onOpenCommandMenu, onToggleSidebar, shortcuts]); } diff --git a/packages/web/src/hooks/use-sandbox-access.ts b/packages/web/src/hooks/use-sandbox-access.ts index d28b3a1d5..28c943041 100644 --- a/packages/web/src/hooks/use-sandbox-access.ts +++ b/packages/web/src/hooks/use-sandbox-access.ts @@ -22,10 +22,11 @@ const sandboxAccessSchema = z type SandboxAccess = z.infer; -export function useSandboxAccess(sessionId: string, isSandboxReady: boolean) { - const key: BrowserApiPath | null = isSandboxReady - ? `/api/sessions/${encodeURIComponent(sessionId)}/sandbox-access` - : null; +export function useSandboxAccess(sessionId: string, isSandboxReady: boolean, enabled = true) { + const key: BrowserApiPath | null = + enabled && isSandboxReady + ? `/api/sessions/${encodeURIComponent(sessionId)}/sandbox-access` + : null; const { data, mutate } = useSWR(key, async (url: BrowserApiPath) => { const response = await browserApiFetch(url, { cache: "no-store" }); if (response.status === 204 || response.status === 404) return null; diff --git a/packages/web/src/hooks/use-session-socket.test.tsx b/packages/web/src/hooks/use-session-socket.test.tsx index 477625509..d08cf333f 100644 --- a/packages/web/src/hooks/use-session-socket.test.tsx +++ b/packages/web/src/hooks/use-session-socket.test.tsx @@ -139,6 +139,28 @@ describe("useSessionSocket", () => { vi.restoreAllMocks(); }); + it("keeps the HTTP snapshot available without collaboration or sandbox requests", async () => { + const fetchMock = vi.mocked(fetch); + const snapshot = createSnapshot(); + snapshot.session.title = "Read-only snapshot"; + + const { result } = renderHook(() => + useSessionSocket("session-1", snapshot, { + collaborate: false, + sandboxAccess: false, + }) + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.sessionState?.title).toBe("Read-only snapshot"); + expect(result.current.connected).toBe(false); + expect(FakeWebSocket.instances).toHaveLength(0); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("keeps sendPrompt pending until the server acknowledges the queued prompt", async () => { const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); diff --git a/packages/web/src/hooks/use-session-socket.ts b/packages/web/src/hooks/use-session-socket.ts index 72ef39868..ec9d622c6 100644 --- a/packages/web/src/hooks/use-session-socket.ts +++ b/packages/web/src/hooks/use-session-socket.ts @@ -99,7 +99,11 @@ interface PendingCorrelatedRequest { */ export function useSessionSocket( sessionId: string, - initialSnapshot: SessionSnapshot + initialSnapshot: SessionSnapshot, + capabilities: { collaborate: boolean; sandboxAccess: boolean } = { + collaborate: true, + sandboxAccess: true, + } ): UseSessionSocketReturn { const [state, dispatch] = useReducer( sessionSocketReducer, @@ -117,7 +121,11 @@ export function useSessionSocket( sandboxAccess, clear: clearSandboxAccess, refresh: refreshSandboxAccess, - } = useSandboxAccess(sessionId, state.sessionState?.sandboxStatus === "ready"); + } = useSandboxAccess( + sessionId, + state.sessionState?.sandboxStatus === "ready", + capabilities.sandboxAccess + ); const settleSubscriptionWaiters = useCallback((subscribed: boolean) => { for (const resolve of subscriptionWaitersRef.current) { @@ -228,10 +236,14 @@ export function useSessionSocket( dispatch({ type: "socket_closed" }); }, [settleAllCorrelatedRequests, settleSubscriptionWaiters]); - const transport = useSessionTransport(sessionId, { - onMessage: handleMessage, - onClose: handleClose, - }); + const transport = useSessionTransport( + sessionId, + { + onMessage: handleMessage, + onClose: handleClose, + }, + capabilities.collaborate + ); const { isOpen, send, reconnect, markHealthy } = transport; useEffect(() => { diff --git a/packages/web/src/hooks/use-session-transport.test.tsx b/packages/web/src/hooks/use-session-transport.test.tsx index 94be721e5..9bdd2f1f6 100644 --- a/packages/web/src/hooks/use-session-transport.test.tsx +++ b/packages/web/src/hooks/use-session-transport.test.tsx @@ -110,6 +110,25 @@ describe("useSessionTransport", () => { expect(result.current.isOpen()).toBe(true); }); + it("does not fetch a token or open a socket when transport is disabled", async () => { + const { result } = renderHook(() => + useSessionTransport("session-1", { onMessage, onClose }, false) + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(FakeWebSocket.instances).toHaveLength(0); + expect(result.current.connected).toBe(false); + expect(result.current.connecting).toBe(false); + + act(() => result.current.reconnect()); + expect(fetchMock).not.toHaveBeenCalled(); + expect(FakeWebSocket.instances).toHaveLength(0); + }); + it("forwards schema-valid messages to onMessage", async () => { const { socket } = await openSocket(); @@ -195,6 +214,26 @@ describe("useSessionTransport", () => { expect(FakeWebSocket.instances).toHaveLength(1); }); + it("clears the token and reconnects after authorization lease expiry", async () => { + vi.useFakeTimers(); + const rendered = renderTransport(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + act(() => { + FakeWebSocket.instances[0].open(); + FakeWebSocket.instances[0].serverClose(4010, true); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + + expect(FakeWebSocket.instances).toHaveLength(2); + expect(fetchMock).toHaveBeenCalledTimes(2); + rendered.unmount(); + }); + it("reconnects with backoff after an unclean close and reuses the cached token", async () => { vi.useFakeTimers(); const rendered = renderTransport(); diff --git a/packages/web/src/hooks/use-session-transport.ts b/packages/web/src/hooks/use-session-transport.ts index d7453f009..9118cad0b 100644 --- a/packages/web/src/hooks/use-session-transport.ts +++ b/packages/web/src/hooks/use-session-transport.ts @@ -19,6 +19,7 @@ const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8787"; const WS_CLOSE_AUTH_REQUIRED = 4001; const WS_CLOSE_SESSION_EXPIRED = 4002; const WS_CLOSE_INVALID_MESSAGE = 4004; +const WS_CLOSE_AUTHORIZATION_REVOKED = 4010; const MAX_RECONNECT_ATTEMPTS = 5; const RECONNECT_BASE_DELAY_MS = 1000; @@ -33,6 +34,7 @@ function reconnectDelayMs(attemptsSoFar: number): number { type CloseDirective = | { action: "auth_required" } | { action: "session_expired" } + | { action: "authorization_revoked"; delayMs?: number } | { action: "retry"; delayMs: number } | { action: "give_up" } | { action: "none" }; @@ -47,6 +49,11 @@ function closeDirective( if (event.code === WS_CLOSE_SESSION_EXPIRED) { return { action: "session_expired" }; } + if (event.code === WS_CLOSE_AUTHORIZATION_REVOKED) { + return attemptsSoFar < MAX_RECONNECT_ATTEMPTS + ? { action: "authorization_revoked", delayMs: reconnectDelayMs(attemptsSoFar) } + : { action: "authorization_revoked" }; + } if (!event.wasClean || event.code === WS_CLOSE_INVALID_MESSAGE) { return attemptsSoFar < MAX_RECONNECT_ATTEMPTS ? { action: "retry", delayMs: reconnectDelayMs(attemptsSoFar) } @@ -85,7 +92,8 @@ export interface UseSessionTransportReturn { */ export function useSessionTransport( sessionId: string, - handlers: SessionTransportHandlers + handlers: SessionTransportHandlers, + enabled = true ): UseSessionTransportReturn { const wsRef = useRef(null); const mountedRef = useRef(true); @@ -234,6 +242,19 @@ export function useSessionTransport( wsTokenRef.current = null; return; + case "authorization_revoked": + wsTokenRef.current = null; + if (!mountedRef.current) return; + if (directive.delayMs === undefined) { + setConnectionError("Authorization could not be refreshed. Please try reconnecting."); + return; + } + reconnectAttempts.current++; + reconnectTimeoutRef.current = setTimeout(() => { + if (mountedRef.current) retry(); + }, directive.delayMs); + return; + case "retry": if (!mountedRef.current) return; reconnectAttempts.current++; @@ -323,6 +344,7 @@ export function useSessionTransport( }, []); const reconnect = useCallback(() => { + if (!enabled) return; // A connect() still awaiting its token must not open a second socket // alongside the one this call creates. invalidateInFlightConnect(); @@ -344,7 +366,7 @@ export function useSessionTransport( setAuthError(null); setConnectionError(null); connect(); - }, [connect, invalidateInFlightConnect]); + }, [connect, enabled, invalidateInFlightConnect]); const markHealthy = useCallback(() => { reconnectAttempts.current = 0; @@ -353,7 +375,7 @@ export function useSessionTransport( // Connect on mount useEffect(() => { mountedRef.current = true; - connect(); + if (enabled) connect(); return () => { mountedRef.current = false; @@ -367,10 +389,11 @@ export function useSessionTransport( discarded.close(); } }; - }, [connect, invalidateInFlightConnect]); + }, [connect, enabled, invalidateInFlightConnect]); // Ping periodically to keep connection alive. useEffect(() => { + if (!enabled) return; const pingInterval = setInterval(() => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: "ping" })); @@ -378,7 +401,7 @@ export function useSessionTransport( }, PING_INTERVAL_MS); return () => clearInterval(pingInterval); - }, []); + }, [enabled]); return { connected, diff --git a/packages/web/src/lib/automation-authorization.test.ts b/packages/web/src/lib/automation-authorization.test.ts new file mode 100644 index 000000000..6b4ad58db --- /dev/null +++ b/packages/web/src/lib/automation-authorization.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import type { EffectiveAuthorization, PermissionId } from "@open-inspect/shared/rbac"; +import { canAccessAutomation } from "./automation-authorization"; + +const CURRENT_USER_ID = "11111111111111111111111111111111"; +const OTHER_USER_ID = "22222222222222222222222222222222"; + +function authorization(permissions: PermissionId[]): EffectiveAuthorization { + return { + userId: CURRENT_USER_ID, + suspendedAt: null, + role: { id: "role-1", key: null, name: "Test" }, + permissions, + }; +} + +describe("canAccessAutomation", () => { + it("allows any scope regardless of ownership", () => { + expect( + canAccessAutomation("automations.manage", authorization(["automations.manage.any"]), { + userId: OTHER_USER_ID, + }) + ).toBe(true); + }); + + it("allows own scope only for the canonical owner", () => { + const auth = authorization(["automations.trigger.own"]); + expect(canAccessAutomation("automations.trigger", auth, { userId: CURRENT_USER_ID })).toBe( + true + ); + expect(canAccessAutomation("automations.trigger", auth, { userId: OTHER_USER_ID })).toBe(false); + expect(canAccessAutomation("automations.trigger", auth, { userId: null })).toBe(false); + }); + + it("denies missing authorization and unrelated capabilities", () => { + expect(canAccessAutomation("automations.manage", null, { userId: CURRENT_USER_ID })).toBe( + false + ); + expect( + canAccessAutomation("automations.manage", authorization(["automations.trigger.any"]), { + userId: CURRENT_USER_ID, + }) + ).toBe(false); + }); +}); diff --git a/packages/web/src/lib/automation-authorization.ts b/packages/web/src/lib/automation-authorization.ts new file mode 100644 index 000000000..798f7e2dc --- /dev/null +++ b/packages/web/src/lib/automation-authorization.ts @@ -0,0 +1,17 @@ +import { + resolveScopedPermission, + type EffectiveAuthorization, + type ScopedPermissionStem, +} from "@open-inspect/shared/rbac"; +import type { Automation } from "@open-inspect/shared/types/automations"; + +/** Checks an automation capability against its canonical owner identity. */ +export function canAccessAutomation( + stem: ScopedPermissionStem, + authorization: EffectiveAuthorization | null, + automation: Pick +): boolean { + if (!authorization) return false; + const scope = resolveScopedPermission(stem, authorization.permissions); + return scope === "any" || (scope === "own" && automation.userId === authorization.userId); +} diff --git a/public/docs/internal/2026-08-28-rbac-design.md b/public/docs/internal/2026-08-28-rbac-design.md new file mode 100644 index 000000000..626d894e1 --- /dev/null +++ b/public/docs/internal/2026-08-28-rbac-design.md @@ -0,0 +1,815 @@ +# Design: Role-Based Access Control + +**Date:** 2026-08-28 + +**Status:** Proposed + +**Research:** [2026-08-28-rbac-research.md](./2026-08-28-rbac-research.md) + +## Summary + +Open-Inspect will add workspace-level RBAC to its existing single-installation identity model. Each +canonical human user is assigned exactly one role. A role contains a set of permissions selected +from a code-owned registry. Four protected built-in roles provide safe defaults. The storage and +resolution model also supports existing custom roles, but custom-role creation and editing are +deferred beyond this foundation. + +Authorization will be enforced in the control plane after authentication and before business logic. +The web will receive effective permissions for navigation and control affordances, but client checks +will remain advisory. Sessions are workspace-wide resources governed by operation permissions, as +specified in +[Workspace-Wide Session Authorization](./2026-08-30-workspace-wide-session-authorization-design.md). +Bot calls will be limited by both a fixed service capability ceiling and, when acting for a human, +that canonical user's current role. + +This design retains one workspace per deployment. It does not add multiple organizations or +per-repository user grants. The SCM App installation continues to define the repository universe; +RBAC determines which application actions a user may perform within that universe. + +## Goals + +- Assign different capability sets to individual canonical users. +- Provide protected Owner, Administrator, Member, and Viewer roles. +- Resolve and assign persisted custom roles from a fixed permission registry. +- Enforce permissions consistently across HTTP routes, session WebSockets, bots, and settings. +- Distinguish authentication, admission, attribution, resource relationships, and authorization. +- Preserve existing installation access during migration without leaving the workspace ownerless. +- Make role assignment and privileged operations durably auditable. +- Apply role changes promptly to new requests and bounded-lifetime live connections. +- Keep the authorization API explicit, typed, testable, and deny-by-default. + +## Non-Goals + +- Multiple workspaces or organizations in one deployment. +- User/group grants for individual repositories or environments. +- Synchronizing roles from GitHub, Google, Slack, Linear, or an identity provider. +- Treating source-control permissions as Open-Inspect roles. +- A general policy language, conditional expressions, deny rules, or arbitrary customer-defined + permission identifiers. +- Billing plans, quotas, approval workflows, or separation-of-duty constraints. +- Modeling Cloudflare, Modal, Terraform, or GitHub deployment operators as application users. +- Changing sandbox-to-control-plane or control-plane-to-Modal machine authentication. +- Making secret values readable after storage. + +## Terminology + +| Term | Meaning | +| --------------------- | --------------------------------------------------------------------------------- | +| Workspace | The singleton administrative boundary represented by one Open-Inspect deployment. | +| Principal | An authenticated human user, first-party service, or session-bound sandbox. | +| Actor | A provider identity asserted by a bot service on behalf of a human. | +| Role | A named collection of registered permissions. | +| Built-in role | A protected role shipped by the application with code-defined permissions. | +| Custom role | A workspace-defined role composed from registered permissions. | +| Permission | A stable `resource.action` identifier checked by backend policy. | +| Relationship | Context such as automation ownership used alongside a scoped permission. | +| Capability ceiling | The maximum permission set a first-party service can exercise. | +| Effective permissions | The permissions produced by the current role, bounded by principal policy. | + +## Decisions + +| Area | Decision | +| ---------------- | ---------------------------------------------------------------------------------------- | +| Tenancy | One implicit workspace per deployment. | +| User assignment | Exactly one role per canonical user. | +| Role model | Four protected built-ins plus custom roles. | +| Permission model | Fixed allow-only registry owned in shared code. Missing permission denies. | +| Enforcement | Control plane is authoritative; web checks are presentation only. | +| Resource scoping | Workspace-wide sessions plus contextual own/any automation actions. | +| Repository scope | SCM installation defines visibility; role permissions govern app operations. | +| Services | Static service ceilings; actor-backed calls use ceiling/actor intersection. | +| Sandboxes | Existing session-bound capability model remains separate from human RBAC. | +| Role changes | Immediate for HTTP; short authorization leases bound live browser connections. | +| Audit | Durable audit events for RBAC changes and sensitive mutations; structured denial logs. | +| Owner bootstrap | Every deployment requires an explicit operator bootstrap after the Owner signs in. | +| Migration | Existing canonical users become Administrator; the operator explicitly bootstraps Owner. | + +## Authorization Model + +### Built-in roles + +The built-in roles are stable system records. Their names and permission sets are defined in code +and cannot be deleted or edited through the application. + +| Role | Intended capability | +| ------------- | --------------------------------------------------------------------------------------------- | +| Owner | Full application access, role management, member management, and ownership transfer. | +| Administrator | Full operational access except ownership transfer and protected Owner changes. | +| Member | Create and operate sessions and automations; use shared targets; no sensitive administration. | +| Viewer | Read shared operational state and session output; no launches or shared-resource mutations. | + +Owner is not represented by a wildcard. It receives every registered permission explicitly when +permissions are resolved. This makes newly introduced permissions visible in review and prevents +custom permission strings from becoming executable. + +### Custom roles + +The data model and permission resolver retain support for persisted custom roles so assignments and +effective authorization do not depend on built-in role keys. This foundation exposes custom roles +through read and assignment APIs only; creating, editing, and deleting them is deferred until there +is a concrete administration workflow. Persisted custom permissions must be registry members, cannot +include `workspace.transfer_ownership`, and remain allow-only without inheritance or deny entries. + +One role per user avoids ambiguous permission union, ordering, and deny precedence. A later group or +multi-role system can expand assignment cardinality without changing permission identifiers or route +checks. + +### Permission registry + +Permissions are exported from `@open-inspect/shared` as stable identifiers and protected built-in +role sets. Built-in policy changes deploy with code and do not require a data migration. Persisted +`role_permissions` rows are the runtime authority only for workspace-defined custom roles. Unknown +identifiers fail role validation and are ignored during effective-permission resolution. Permission +IDs are never reused for different semantics. + +### Permission catalog + +#### Workspace and identity + +| Permission | Actions | +| ------------------------------ | --------------------------------------------------------------------- | +| `workspace.members.read` | List users, identities, roles, and assignment state. | +| `workspace.members.manage` | Assign roles other than Owner; suspend or restore application access. | +| `workspace.roles.read` | List role definitions and permission catalog. | +| `workspace.transfer_ownership` | Assign/remove Owner while preserving at least one Owner. | + +#### Repositories and environments + +| Permission | Actions | +| ------------------------------ | ----------------------------------------------------------------- | +| `repositories.read` | List installed repositories, branches, and metadata. | +| `repositories.use` | Select repositories as session or automation targets. | +| `repositories.settings.manage` | Change repository SCM, sandbox, and integration overrides. | +| `repositories.secrets.manage` | Create, update, or delete repository secrets. | +| `repositories.images.manage` | Toggle or trigger repository image builds. | +| `environments.read` | List and inspect environments and memberships. | +| `environments.use` | Select environments as session or automation targets. | +| `environments.manage` | Create, update, or delete environments and repository membership. | +| `environments.settings.manage` | Change environment integration and sandbox overrides. | +| `environments.secrets.manage` | Create, update, delete, or import environment secrets. | +| `environments.images.manage` | Toggle or trigger environment image builds. | + +#### Sessions + +| Permission | Actions | +| ------------------------- | --------------------------------------------------------------------- | +| `sessions.create` | Create a session using an allowed target. | +| `sessions.read` | Read every workspace session. | +| `sessions.collaborate` | Prompt, attach files, and connect to every workspace session. | +| `sessions.lifecycle` | Rename, archive, unarchive, stop, cancel, and refresh any session. | +| `sessions.delete` | Delete any workspace session. | +| `sessions.sandbox_access` | Obtain terminal, VNC, code-server, or sandbox access for any session. | + +Session creator and participant data are attribution and runtime identity, not authorization. +Read-state changes require `sessions.read` and always mutate only the caller's own read state. + +#### Automations and analytics + +| Permission | Actions | +| ------------------------- | ---------------------------------------------------------------------------- | +| `automations.read` | List automation definitions and run history. | +| `automations.create` | Create an automation with allowed targets and provider mode. | +| `automations.manage.own` | Edit, pause, resume, rotate keys, or delete automations created by the user. | +| `automations.manage.any` | Manage any automation. | +| `automations.trigger.own` | Manually execute an automation created by the user. | +| `automations.trigger.any` | Manually execute any automation. | +| `analytics.read` | View installation-wide session, repository, user, and PR analytics. | + +#### Models, integrations, and execution configuration + +| Permission | Actions | +| --------------------------- | -------------------------------------------------------------------------- | +| `models.preferences.manage` | Change enabled model preferences. | +| `provider_accounts.read` | View provider account metadata, status, and defaults. | +| `provider_accounts.manage` | Connect, reconnect, rename, verify, enable, disable, and default accounts. | +| `integrations.read` | View integration, SCM, sandbox, and commit-signing metadata. | +| `integrations.manage` | Change global integration and sandbox settings. | +| `scm_settings.manage` | Change deployment-wide SCM settings. | +| `commit_signing.manage` | Configure or remove deployment-wide signing material. | +| `global_secrets.manage` | Create, update, or delete global secrets. | +| `image_builds.read` | View repository/environment image build status and history. | + +#### Extensibility + +| Permission | Actions | +| --------------------------- | ------------------------------------------------------------------------- | +| `skills.read` | List shared managed skills. | +| `skills.manage` | Import, edit, assign, reimport, enable, disable, or delete shared skills. | +| `skill_profiles.manage_own` | Manage only the caller's skill profiles. | +| `mcp_servers.read` | List MCP server definitions. | +| `mcp_servers.manage` | Create, update, or delete MCP server definitions. | + +Personal keyboard shortcuts and browser-local appearance require only an authenticated, active user. +They do not need role permissions because they cannot affect another user or shared execution. + +### Default role matrix + +The table groups permissions for readability; the registry stores individual identifiers. + +| Capability group | Owner | Administrator | Member | Viewer | +| -------------------------------------------------------- | :---: | :-----------: | :------: | :----: | +| Workspace, member, role, and audit read | Yes | Yes | No | No | +| Manage members | Yes | Yes | No | No | +| Transfer Owner role | Yes | No | No | No | +| Read repositories and environments | Yes | Yes | Yes | Yes | +| Use repositories and environments | Yes | Yes | Yes | No | +| Read image-build status and history | Yes | Yes | Yes | Yes | +| Manage environments/settings/images | Yes | Yes | No | No | +| Manage global/repository/environment secrets | Yes | Yes | No | No | +| Create sessions | Yes | Yes | Yes | No | +| Read any session | Yes | Yes | Yes | Yes | +| Collaborate in any session | Yes | Yes | Yes | No | +| Perform session lifecycle operations | Yes | Yes | Yes | No | +| Delete sessions | Yes | Yes | Yes | No | +| Obtain sandbox access | Yes | Yes | Yes | No | +| Read automations | Yes | Yes | Yes | Yes | +| Create/manage/trigger automations | Yes | Yes | Own only | No | +| Read analytics | Yes | Yes | Yes | Yes | +| Manage models/provider accounts/integrations/SCM/signing | Yes | Yes | No | No | +| Read shared skills and MCP servers | Yes | Yes | Yes | Yes | +| Manage shared skills and MCP servers | Yes | Yes | No | No | +| Manage own skill profiles | Yes | Yes | Yes | No | +| Manage personal preferences | Yes | Yes | Yes | Yes | + +Viewer receives `sessions.read` but no collaborate or lifecycle permission. Member receives every +non-administrative session operation across the workspace. Administrator preserves the existing +broad operational behavior. + +## Data Model + +### Tables + +```sql +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)) +); + +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); +``` + +Built-in roles have stable `key` values: `owner`, `administrator`, `member`, and `viewer`; their +permission sets come from the shared code registry and have no `role_permissions` rows. Custom roles +have `key = NULL`, and their permission rows are the runtime authority. IDs are opaque; role names +are display values. This foundation does not expose custom-role mutations. + +`users` gains: + +```sql +ALTER TABLE users ADD COLUMN suspended_at INTEGER; +``` + +Suspension records the time access was disabled without deleting identities or historical +attribution. A null value means the user is active. + +Every canonical identity is an active workspace member unless suspended. The RBAC migration seeds +the built-in roles, assigns Administrator to every existing canonical user, and then creates the +default-role trigger. Every identity created afterward receives Member, including identities first +observed through a bot. Identity creation and default role assignment are one database-triggered +workflow. Authorization denies a missing assignment; ordinary sign-in and identity resolution never +repair authorization corruption implicitly. + +Initial ownership is assigned only by the root operator CLI after the intended Owner has signed in +once. The operator supplies the canonical user ID, not an email or browser credential. One temporary +SQL file and one Wrangler D1 execution validate the RBAC schema, unsuspended user, exact assignment, +and absence of another unsuspended Owner before atomically writing a redacted `operator-cli` audit +event and assigning `role_builtin_owner`. The final SQL guard verifies the exact generated audit ID +and aborts the operation if the resulting state is inconsistent. Re-running for the current +unsuspended Owner is a no-op and writes nothing. Ownership changes after initialization use the +authenticated member API. + +### Storage ownership + +- D1 is the source of truth for roles, assignments, status, custom-role grants, and audit events. +- Shared code defines the permission catalog and built-in role grants; persisted permission rows are + the runtime grant authority for custom roles. +- Session creator attribution remains in D1 and is not an authorization relationship. +- Participant attribution remains in the Session Durable Object for message identity, presence, SCM + metadata, and WebSocket tokens. +- No role or permission set is copied into sessions, automations, or provider accounts. + +## Policy Engine + +### Interface + +Authorization is invoked through one control-plane service rather than direct role-table queries in +handlers: + +```ts +type AuthorizationRequest = { + principal: Principal; + permission: PermissionId; + resource?: AuthorizationResource; +}; + +type AuthorizationDecision = { + allowed: boolean; + reason: AuthorizationReason; + actorUserId: string | null; +}; +``` + +The engine exposes `requirePermission()` for ordinary checks and an automation resource helper for +owner-scoped automation policy. Denial throws a typed `403` error with a stable reason code. +Authentication failures remain `401`; missing resources remain `404` after permission admission. + +### Human decision flow + +1. Require an active canonical user. +2. Load the user's role assignment and registered permission set. +3. Deny if no assignment exists. +4. Check the requested permission. +5. For owner-scoped automation permissions, load the automation owner. +6. Return an allow/deny decision with a stable reason. + +### Service decision flow + +Each service has a code-defined ceiling: + +- `web` may proxy browser-auth and discovery operations only; browser application routes authorize + the human user principal produced by composed authentication. +- `github-bot` may read repository/environment launch metadata, create sessions, read, prompt, or + stop workspace sessions, and post GitHub automation events. +- `slack-bot` may read launch catalogs/preferences, create sessions, operate sessions mapped to its + Slack thread, upload/download session media, and post Slack events. +- `linear-bot` may read launch catalogs/preferences, create sessions, and operate sessions mapped to + its Linear issue/agent session. + +For an actor-backed service request: + +```text +effective = service ceiling ∩ actor role permissions +``` + +The actor must resolve to an active canonical user with a role assignment. Service-authenticated +identity enrollment resolves or creates the canonical identity before business authorization and +idempotently assigns the migration default: Administrator for identities captured by the migration, +Member afterward. A first bot interaction can therefore proceed with Member capabilities but can +never claim Owner. Provider webhook verification and GitHub collaborator checks remain additional +admission conditions, never substitutes for application authorization. + +Actorless callbacks, normalized webhook events, and automation triggers use narrow service-only +permissions declared for their exact endpoints. They cannot use broad `user-or-service` management +routes. + +### Sandbox decision flow + +Sandbox authentication remains a scoped capability. A valid sandbox principal can call only route +operations explicitly designated for a sandbox bound to the same session. It does not inherit the +session creator's role and does not gain workspace permissions. Human role changes do not terminate +an executing sandbox, but they can remove human access to its session and controls. + +### Session authorization and identity + +Session operations are workspace-scoped. A user with a session operation permission may apply it to +every session, regardless of creator or participant identity. Deletion is also workspace-scoped. + +`sessions.user_id` retains immutable creator attribution for display, filtering, auditing, and +credential lineage. Session Durable Object participants retain message identity, presence, SCM +metadata, and WebSocket token ownership. Neither is an authorization grant. + +Creating a WebSocket token or sending a prompt requires `sessions.collaborate`. WebSocket +subscription rechecks the represented canonical user's active role and collaboration permission. +Private, invitation-only, participant-restricted, and creator-only session behavior is deferred. + +### Automation execution authority + +Automation definitions retain a canonical owner. Every invocation reauthorizes current state rather +than replaying stored creator authority: + +| Trigger | Initiating actor | Execution principal | Required current authority | +| ------------ | ----------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------- | +| Manual | Requesting user | Requesting user | own/any trigger, target use, session create | +| Schedule | Scheduler service | Automation owner | active owner, manage-own, target use, session create | +| Webhook key | Narrow webhook capability | Automation owner | active owner, manage-own, target use, session create | +| Sentry | Verified Sentry webhook | Automation owner | active owner, manage-own, target use, session create | +| GitHub event | Verified GitHub service actor | Canonical GitHub actor | service ceiling; active actor with session create and target use; active owner with manage-own | +| Slack event | Verified Slack service actor | Canonical Slack actor | service ceiling; active actor with session create and target use; active owner with manage-own | +| Linear event | Verified Linear service actor | Canonical Linear actor | service ceiling; active actor with session create and target use; active owner with manage-own | + +The resulting session is owned by and attributed to the named canonical execution principal. The +initiator, service, and automation owner are recorded separately in invocation/audit metadata. Skill +profiles and user-linked credentials come from the execution principal; installation-wide secrets +and provider accounts remain selected by the automation's current allowed configuration. A manual +trigger never runs as another user's stored identity. Loss of any conjunctive authority marks the +invocation `skipped_authorization` without launching a session. Repeated scheduled or webhook +authorization failures pause the automation after the existing failure threshold and notify +administrators. Provider-account and secret resolution is repeated under the current execution +policy. + +New automations require an active canonical owner. Historical automations with missing or unresolved +owners are disabled during migration and require explicit reassignment by an Administrator or Owner +before execution. + +## Route Enforcement + +### Route metadata + +Authentication policy remains responsible for proving principal kind. Every route declaration also +contains required authorization metadata. Static permission routes declare the permission beside the +method and pattern: + +```ts +authorization: requirePermission("environments.manage"); +``` + +Session routes identify the operation applied to the already-matched path parameter. Conjunctive +policies list every requirement explicitly: + +```ts +authorization: requireAll( + permissionRequirement("sessions.create"), + permissionRequirement("sessions.collaborate") +); +``` + +The router executes declared permission, session-operation, and automation checks before handlers. +Request admission uses current authorization; a concurrent role change does not retroactively revoke +an admitted HTTP request. Personal active-user routes, active global routes, public routes, and +service-only callbacks each use an explicit policy kind; narrow internal callbacks name their exact +service. `router.policy.test.ts` rejects missing metadata, duplicate method/pattern pairs, +incompatible authentication/authorization combinations, and session requirements that reference +absent match groups. + +### Exemptions + +Only these ingress/authentication classes bypass browser authentication: + +- public health; +- browser-auth protocol endpoints; +- externally authenticated webhook ingress; +- image-build capability callbacks; +- session-bound sandbox routes; +- narrow internal service callbacks. + +Each exemption names its alternate ingress mechanism in route metadata. Webhook authenticity permits +normalization/queueing only; every resulting automation or resource operation still applies the +execution-authority policy before side effects. `user-or-service` alone is never sufficient +authorization after this change. + +A generated route-to-policy inventory covers every session, child-session, attachment, media, diff, +pull-request, credential, automation, secret, settings, and callback endpoint. Sandbox child +operations remain parent-session-bound; human child operations use workspace session permissions. + +### Listing and filtering + +Authorization applies before list queries, with contextual automation ownership applied in SQL where +needed. + +- Every user with `sessions.read` receives the workspace session list. +- Creator and Mine filters use `sessions.user_id` as attribution, not access control. +- Automation lists use `manage.any/read` or creator ownership as appropriate. +- Resources requiring a missing read permission are omitted from catalogs and navigation. +- Repository/environment catalogs require read permission; use permission is separately checked when + launching or configuring an execution target. + +## API Contracts + +### Current user authorization + +`GET /me/authorization` returns: + +```json +{ + "userId": "canonical-id", + "suspendedAt": null, + "role": { "id": "role-id", "key": "member", "name": "Member" }, + "permissions": ["repositories.read", "sessions.create"] +} +``` + +This endpoint is available only to the current browser user. Responses are private and no-store. + +### Role administration + +| Method | Path | Permission | Purpose | +| ------ | ------------ | ---------------------- | ------------------------------------ | +| `GET` | `/roles` | `workspace.roles.read` | List roles, counts, and permissions. | +| `GET` | `/roles/:id` | `workspace.roles.read` | Read one role and permissions. | + +### Member administration + +| Method | Path | Permission | Purpose | +| ------ | ------------------------- | -------------------------------------- | ------------------------------------- | +| `GET` | `/members` | `workspace.members.read` | List canonical users and assignments. | +| `PUT` | `/members/:userId/role` | `workspace.members.manage` or transfer | Replace one assignment. | +| `PUT` | `/members/:userId/status` | `workspace.members.manage` | Suspend or restore access. | + +Owner assignment or removal requires `workspace.transfer_ownership`, including when the caller also +has member-management permission. Suspending, deleting, or merging an Owner also requires transfer +permission. Every role/status/delete/merge mutation uses guarded SQL that succeeds only if another +unsuspended Owner remains in the same D1 batch. User deletion is blocked by assignment +`ON DELETE RESTRICT`; the assignment can be removed only through this guarded membership service. +User merge requires an explicit surviving assignment, repoints canonical session creator +attribution, and preserves both immutable audit snapshots. + +Assignment and status updates apply the request-scoped authorization decision and preserve Owner +invariants in the same D1 batch as the mutation. Authorization changes do not retroactively revoke +an already admitted request. + +### Error contract + +Forbidden API responses use: + +```json +{ + "error": "Forbidden", + "code": "permission_required", + "permission": "environments.manage" +} +``` + +Other denials use codes such as `active_user_required` and `service_capability_required`. Responses +do not disclose another user's role. + +## Web Experience + +### Authorization state + +The app shell loads current authorization with the browser session. It distinguishes: + +- unauthenticated; +- authenticated but suspended/unassigned; +- authenticated and authorized; +- authorization service unavailable. + +Permission checks consume the stable `hasPermission` predicate from the current-user authorization +hook. They hide navigation that has no readable content and disable contextual controls when +explaining the missing capability is useful. Server-rendered session pages authorize before fetching +snapshots. + +### Members and roles + +A Workspace settings section contains: + +- Members: identity, provider links, status, role, last activity, and assignment actions. +- Roles: built-in/custom roles, assignment count, and categorized permission details. +- Audit log: actor, action, target, outcome, reason, and timestamp. + +The UI prevents removing the last unsuspended Owner and assigning Owner without transfer permission. +The API repeats every invariant. + +### Existing navigation + +- Settings tabs appear only when at least one permission makes them useful. +- New session requires `sessions.create` plus target `use` permission. +- All/Mine becomes All/My sessions; both are filters over the workspace-wide session list. +- Session controls reflect read, collaborate, lifecycle, delete, and sandbox-access permissions + independently. +- Analytics requires `analytics.read`. +- Automation create/manage actions are independent from automation read access. + +The browser never treats hidden controls or downloaded permissions as security enforcement. + +## Audit and Observability + +Durable audit events are required for: + +- user role assignment; +- access suspension/restoration; +- Owner assignment/removal; +- secret, provider-account, commit-signing, integration, SCM, MCP, and shared-skill mutations; +- allowed and denied member-management operations. + +Pure D1 mutations write the audit event in the same D1 batch. + +High-volume ordinary reads and successful session messages remain in structured request logs rather +than D1 audit storage. Every authorization denial logs principal kind, actor user ID when known, +permission, policy, resource type, opaque resource ID, reason code, request ID, and service name. +Secret values, OAuth credentials, prompt content, and signed tokens never enter audit metadata. + +Metrics include denial count by permission/reason/principal, unassigned active users, assignment +count by role, and authorization latency. + +## Role Changes and Revocation + +- HTTP requests load current assignment/status and apply changes immediately. +- Role permission edits take effect on the next authorization lookup. +- Browser WebSocket credentials are bound to the canonical user. Subscribe verifies current D1 + authorization and rejects missing or suspended users, missing role assignments, and unavailable + authorization storage. +- A successful subscribe asks the WebSocket manager to grant a five-minute wall-clock authorization + lease. The manager persists its expiry in `ws_client_mapping` and owns earliest-expiry scheduling + in the unified alarm. On expiry the browser clears its credential and reconnects through the + authorized HTTP token route. +- Alarm and hibernation restoration close every expired connection even when it is idle. Every + inbound event and outbound broadcast also rejects expired leases as defense in depth. A role + change therefore revokes live browser access within the five-minute wall-clock lease bound. +- Bot calls authorize on every signed HTTP request. Stale Slack/Linear issue mappings do not bypass + current policy. +- Suspending a user invalidates Better Auth sessions. +- Existing sandboxes continue running because their credentials represent the session runtime, not + the user. Users who lose lifecycle permission cannot reconnect or control them. + +## Migration and Compatibility + +The migration is additive and preserves current capability for every canonical user: + +1. Create role, permission, assignment, and audit tables. +2. Insert protected built-in role records; their permission sets remain code-owned. +3. Assign Administrator to every canonical user present in `users`, including identities originally + created through Slack, GitHub, or Linear. +4. Create the unconditional default-role trigger. Identity provisioning after this point assigns + Member. + +No route switches to enforcement until every existing canonical user has an Administrator assignment +and built-in role reconciliation succeeds. Administrators may continue using the application before +Owner bootstrap. After deployment, the intended Owner signs in once to create a canonical user and +assignment. An operator then dry-runs and executes the root CLI against that canonical ID. Sign-in +and bot identity creation never assign Owner. + +Deployment documentation will state that Administrator preserves the previous installation-wide +operational behavior, while Member becomes the default for newly admitted users. + +### Operator bootstrap + +Terraform exports the D1 database name but does not configure an Owner identity. The supported +sequence is deploy, have the intended Owner sign in once, obtain the canonical ID from the browser +session, run `npm run rbac:bootstrap-owner -- --database --user `, review the dry-run +preflight, rerun with `--execute`, and verify `/health` reports `rbac.ownerAssignment=present`. + +When an unsuspended Owner assignment exists, `/health` reports `rbac.ownerAssignment=present`; when +none exists, it reports `missing`. Administrators and Members can use their existing capabilities, +but no one can exercise Owner-only actions. + +## Failure Handling + +- D1 authorization lookup failure denies the request and returns `503 authorization_unavailable`; it + never falls back to broad authenticated access. +- Missing or unknown role permissions deny and emit a reconciliation error. +- Missing user assignment denies shared application routes but permits sign-out and own identity + discovery so an administrator can repair access. +- Audit-write failure aborts transactional D1 administration. +- Web authorization metadata failure renders an unavailable state rather than the unrestricted app. + +## Security Invariants + +1. Authentication never implies authorization. +2. Admission allowlists never imply a role beyond bootstrap/default assignment. +3. Unknown permissions, missing assignments, suspended users, and policy errors deny access. +4. Client-side permission checks are never authoritative. +5. Creator and participant attribution are not authorization checks. +6. A service cannot exceed its code-defined ceiling. +7. An actor-backed service cannot exceed the linked user's current permissions. +8. An actorless service can execute only exact service-only operations. +9. Sandbox credentials remain bound to one session and confer no workspace role. +10. Before bootstrap, no user can exercise Owner-only actions; after bootstrap, at least one + unsuspended Owner always exists. +11. Only an Owner can add or remove Owner assignments. +12. Role changes and privileged mutations produce durable, redacted audit events. +13. Session lists require workspace read permission before returning metadata. +14. Secret-management permission never makes stored secret values readable. +15. External provider authorization is additional evidence, not a replacement for application RBAC. + +## Testing Strategy + +### Shared + +- Permission registry uniqueness and stable serialization. +- Built-in role snapshots and persisted custom-role resolution. +- API schema rejection of malformed role responses and assignments. + +### Control-plane unit + +- Human permission allow/deny matrix for every built-in role. +- Custom role resolution, suspension, missing assignment, and unknown permission behavior. +- Workspace-wide session operation permissions for every built-in role. +- Service ceiling and actor intersection for every bot. +- Actorless exact-endpoint service permissions. +- Last-Owner, built-in-role, assignment, and transaction invariants. +- Concurrent Owner demotion/suspension/delete and user-merge conflicts. +- Stable `401`, `403`, `404`, and `503` behavior. +- Route policy completeness requiring authorization metadata or named exemption. + +### Control-plane integration + +- Multi-user tests proving permitted Members can read, collaborate, manage lifecycle, access the + sandbox, and delete across workspace sessions. +- Viewer can read but cannot prompt, launch, stop, delete, or access sandbox credentials. +- Administrator can operate installation-wide resources but cannot transfer Owner. +- Owner can assign roles without removing the last unsuspended Owner. +- Secret/settings/provider-account/skill/MCP/image routes enforce individual permissions. +- Session lists remain workspace-wide while creator and Mine filters preserve attribution semantics. +- Role changes are enforced when idle, active, hibernated, and multi-tab WebSocket authorization + leases expire. +- Suspended browser sessions and bot actors are denied. +- D1 failure fails closed and audit failure aborts protected mutations. +- Automation schedule, webhook, event, and manual triggers reauthorize the correct execution + principal after owner suspension, demotion, role edit, and target-access loss. +- Sentry, GitHub, Slack, and Linear trigger tests assert session owner, initiator audit fields, + owner guard, service ceiling, actor permission intersection, and credential/profile source. + +### Web + +- Navigation and controls for Owner, Administrator, Member, Viewer, custom, suspended, and + unavailable states. +- Direct URL access remains denied when navigation is hidden. +- Session server rendering does not fetch unauthorized snapshots. +- Workspace member controls enforce API invariants. +- Generic forbidden responses do not trigger sign-in flows. + +### Bots + +- Each service can call only its ceiling routes. +- Linked actor role is required for actor-backed launches and prompts. +- Unlinked, suspended, and underprivileged actors fail closed with user-safe provider responses. +- Existing GitHub collaborator, Slack webhook, and Linear organization checks remain enforced. +- External session mappings cannot bypass actor role or service ceiling checks. + +### Migration + +- Empty installation assigns Member to new identities and requires an explicit canonical-ID operator + bootstrap for the initial Owner. +- Existing installation assigns every pre-migration canonical user Administrator, including bot-only + identities, then requires the same explicit operator bootstrap. +- Every canonical user receives exactly one assignment. +- Built-in role reconciliation is idempotent and rejects incompatible registry drift. +- Exact migration SQL executes under workerd/D1, including indexes and constraints. +- Better Auth or bot identity creation followed by assignment failure cannot enter business routes + and retries Member assignment idempotently. +- Owner bootstrap requires an existing unsuspended canonical user with exactly one assignment and + refuses another unsuspended Owner. +- CLI bootstrap is atomic and idempotent, writes exactly one redacted operator audit event on a + ready transition, and writes nothing when the target is already the current Owner. + +## Alternatives Considered + +### Role column on `users` + +Rejected because it cannot represent custom role metadata and permission composition without +hard-coding authorization throughout handlers. + +### Multiple roles per user + +Rejected for the initial system because role union and future deny semantics add complexity without +a current user requirement. One assignment directly matches user-level role configuration. + +### Per-repository and per-environment grants + +Deferred because current deployment identity and repository discovery are installation-wide. Adding +resource grants would require group semantics, environment membership rules, bot grant mapping, and +SCM synchronization decisions not resolved by current product behavior. + +### Encode permissions in browser sessions + +Rejected because role changes would remain stale for the Better Auth session lifetime and backend +handlers would still need authoritative policy state. + +### Use Session Durable Object participant roles as application RBAC + +Rejected because those roles exist only inside one session, are auto-created by current workflows, +and cannot govern installation settings or repository/environment actions. + +### External policy engine + +Rejected because the initial policy consists of a small fixed permission registry plus contextual +automation ownership. D1 and typed control-plane policy keep the trust boundary and operational +footprint within the existing architecture. + +## Open Product Decisions + +The design chooses defaults for implementation, but product confirmation is required before +enforcement: + +1. Session operations are workspace-wide when granted by the user's role. +2. New canonical users default to Member after the RBAC migration boundary. +3. Administrator receives all operational permissions except ownership transfer. +4. Persisted custom roles cannot receive ownership transfer. +5. Repository and environment access remains installation-wide rather than user-granted. +6. Existing users are promoted to Administrator to preserve current access. +7. Executing sandboxes continue after their creator is suspended or demoted. +8. Authorization audit events are retained under the deployment's existing D1 retention policy. +9. Scheduled/webhook automations stop launching when their owner loses current execution authority. +10. Session creator and participant identities are attribution, not authorization. +11. Five minutes is a strict wall-clock browser WebSocket revocation bound, including idle sockets. diff --git a/public/docs/internal/2026-08-28-rbac-research.md b/public/docs/internal/2026-08-28-rbac-research.md new file mode 100644 index 000000000..8384e78bb --- /dev/null +++ b/public/docs/internal/2026-08-28-rbac-research.md @@ -0,0 +1,386 @@ +# Research: Role-Based Access Control + +**Date:** 2026-08-28 + +**Status:** Superseded research snapshot + +**Scope:** Current identity, authentication, authorization, resources, actions, storage, user +workflows, service integrations, and operational trust boundaries relevant to application RBAC. + +The implemented model is documented in [Role-Based Access Control](./2026-08-28-rbac-design.md). + +This document is intentionally research-only. It does not include recommendations, implementation +plans, proposed code/API/schema changes, task breakdowns, estimates, or rollout steps. + +## Summary + +Open-Inspect authenticates human users, first-party services, and session-bound sandboxes, but it +does not have an application role, workspace membership, permission, grant, or administrator model. +The deployment is explicitly single-tenant: admission policy determines who may sign in, and an +admitted human generally shares installation-wide access to repositories, sessions, environments, +secrets, settings, provider accounts, automations, skills, MCP servers, image controls, and +analytics. + +Human identity is canonicalized across GitHub, Google, Slack, and Linear. First-party bots sign +requests as distinct services and may assert actors in their own provider namespace. Sandboxes use +credentials bound to one session. These principal distinctions constrain authentication channels, +but most route policies do not distinguish capabilities among admitted humans or among signed bot +services. + +Sessions contain `owner` and `member` participants, but those roles are not a general authorization +boundary. Session creator fields primarily support attribution and filtering. Existing visibility +logic deliberately returns any session in the installation, and authenticated users or services can +join, prompt, inspect, stop, or mutate many sessions without an owner check. + +The application has three broad resource scopes today: per-user preferences, session-scoped runtime +state, and installation-wide operational resources. Repository and environment resources do not have +application membership or grant records. External source-control permissions are consulted in some +GitHub bot trigger paths, but ordinary web and service access uses the deployment's SCM App or token +authority. + +## Research Questions + +1. Which identities and authentication channels exist today? +2. Which application resources and actions would intersect with authorization decisions? +3. Which resources are personal, session-scoped, repository/environment-scoped, or + installation-wide? +4. Where are authorization decisions currently made, and what do they enforce? +5. How do Slack, GitHub, Linear, sandboxes, and deployment operators cross trust boundaries? +6. Which current fields represent attribution rather than ownership or access? +7. Which gaps and unresolved product semantics affect an RBAC design? + +## Current Behavior + +### Human identity and admission + +- Canonical users are stored in D1 `users`; provider identities are stored in `user_identities` and + linked by canonical user ID. +- Browser sign-in supports GitHub and Google through Better Auth. Browser requests reach the control + plane through a signed `service:web` channel and a valid browser session cookie. +- Admission supports GitHub login, email, email domain, and GitHub organization allowlists, plus an + explicit unsafe allow-all mode. Admission only controls sign-in eligibility. +- The browser session contract exposes user ID, name, email, and image. It has no role, permission, + membership, workspace, or resource-grant data. +- Canonical user IDs currently scope keyboard shortcuts, managed-skill profiles, session read state, + temporary provider-account authorization transactions, and the session-list `Mine` filter. + +### Request principals and route policies + +The control plane resolves every authenticated request to one of: + +| Principal | Identity boundary | Current use | +| ------------------- | ----------------------------------------- | -------------------------------------------------- | +| Human user | Canonical user ID | Browser-originated application requests | +| First-party service | Service name plus optional asserted actor | Web, Slack, GitHub, and Linear Workers | +| Sandbox | Session ID | Session runtime callbacks and credential brokerage | + +Route authentication distinguishes public, handler-authenticated, web-service, human-user, +user-or-service, sandbox, and sandbox-fallback requests. It does not express application actions, +resource scopes, user roles, or grants. Human-only routes exclude bots but admit every authenticated +human. Most `user-or-service` routes admit every signed first-party service, not a named subset. + +### Session visibility and participation + +- Session creation stores a canonical creator in the D1 session index and creates a Durable Object + participant with role `owner`. +- Other identities are added as `member` participants when they request a WebSocket token or send a + prompt. +- `SessionIndexStore.getVisibleForUser()` deliberately ignores the supplied user ID and returns any + existing session. Its source comment names this the single-tenant visibility boundary. +- Session lists are global unless `createdBy=me` is supplied as an explicit filter. +- Session title, archive, and unarchive handlers require participation, but do not distinguish + `owner` from `member`. Other lifecycle and runtime routes do not consistently require existing + participation. +- An authenticated user or asserted service actor can request a WebSocket token for a session and be + added as a member. Prompt submission follows the same auto-membership pattern. +- Deletion, stop, event, artifact, media, attachment, participant, pull-request, and other session + operations generally rely on route authentication and a supplied session ID rather than creator or + participant ownership. +- Sandbox credentials are verified against the Session Durable Object and cannot authenticate to a + different session. Child-sandbox fallbacks are also bound to their parent session. + +### Installation-wide resources + +The following resources are shared across admitted users in the current deployment model: + +| Resource | Read actions | Mutation or execution actions | +| ------------------------ | ------------------------------------------- | ------------------------------------------------------------ | +| Repository catalog | List repositories, branches, metadata | Use as session/environment/automation targets | +| Global secrets | List key metadata | Create/update/delete values | +| Repository secrets | List key metadata | Create/update/delete values | +| Environments | List/view | Create/update/delete; manage repositories and branches | +| Environment secrets | List key metadata | Create/update/delete/import values | +| Integration settings | View global/repository/environment settings | Enable, update, override, reset | +| SCM and sandbox settings | View configuration | Update/reset defaults and overrides | +| Model preferences | View enabled models | Change installation-wide model visibility | +| Provider accounts | List/status | Connect, reconnect, rename, verify, enable, disable, default | +| Automations | List/view runs | Create, edit, trigger, pause, resume, delete, rotate key | +| Managed shared skills | List/view | Import, edit, assign, reimport, delete | +| MCP servers | List/view | Create, edit, delete commands, headers, and environment | +| Image builds | View status/feed | Toggle prebuilds, trigger builds | +| Commit signing | View metadata | Configure/update/delete signing material | +| Analytics | View installation aggregates | No primary mutation workflow | + +Environments have no owner, member, team, role, or ACL columns. Repository access is based on the +deployment's SCM App installation or configured token. Generic settings and secret stores are not +keyed by user. Provider-account creator/updater IDs and automation creator fields record attribution +but do not restrict later access. + +### Personal and local resources + +- Keyboard shortcut preferences are stored by canonical user ID. +- Managed-skill profiles are associated with a canonical user, while the shared skill catalog is + installation-wide. +- Session read states are stored by `(user_id, session_id)` but rely on the broad session visibility + boundary. +- Provider-account device-authorization transactions are user-scoped while in progress; completed + provider accounts are installation-wide. +- Appearance and syntax preferences are browser-local. +- Slack and Linear bot preferences are provider-user-scoped in their Workers' KV stores. + +### Web application behavior + +- `AppAuthBoundary` gates the application shell on authentication state only. +- The sidebar exposes new session, all/mine sessions, settings, automations, analytics, and archived + sessions to every authenticated user. +- Settings navigation is identical for all authenticated users except for deployment-capability + checks such as repository-image support. +- Session controls react to lifecycle, connection, and loading state, not participant role. +- No client condition was found for an administrator flag, role, permission list, repository grant, + environment membership, session owner role, or creator equality. +- The client does not currently represent an authenticated-but-forbidden state distinct from sign-in + admission denial, aside from generic API errors. + +## Relevant Workflows + +### Browser request + +1. GitHub or Google OAuth establishes a Better Auth browser session. +2. The Next.js server signs the control-plane request as `service:web` and forwards the browser + cookie. +3. The control plane verifies both channel and browser identity and creates a user principal. +4. The route policy checks principal kind and SCM compatibility. +5. The handler reads or mutates the requested resource; most handlers have no additional user-level + access check. + +### Bot-created session + +1. A bot verifies an external Slack, GitHub, or Linear webhook. +2. The bot signs a control-plane request with its per-service secret and may assert the external + actor in its namespace. +3. The control plane verifies the service and actor namespace, resolves or creates a canonical user, + and derives session identity from the principal. +4. Session creation requires an actor-backed participant. Existing-session prompts may be actorless + and are then attributed to `anonymous`. +5. The selected repository or environment is resolved using deployment-wide catalogs and + credentials. GitHub trigger flows additionally enforce configured allowlists or GitHub + write-level collaborator permissions; Slack and Linear do not perform equivalent SCM-user checks. + +### Session collaboration + +1. A browser or bot addresses a session by ID. +2. A WebSocket-token or prompt request can create a `member` participant automatically. +3. The Session Durable Object stores participants, messages, artifacts, diffs, repositories, sandbox + state, and credentials. +4. Participant role is returned in shared session types, but the web does not consume it as an + authorization signal. + +### Sandbox runtime + +1. The control plane creates and hashes a per-session sandbox token. +2. The token and session configuration are injected into the sandbox. +3. Sandbox requests are authenticated against the session ID in the route. +4. Session-bound routes broker SCM credentials, provider access, commit signing, skills, + attachments, and runtime events. +5. The sandbox is not represented as a human role and cannot authenticate outside its bound session + through the sandbox credential. + +### Deployment and data plane + +1. GitHub Actions and Terraform provision Cloudflare, D1, R2, Workers, service secrets, and Modal. +2. Deployment operators hold authority outside the application's principal model through source + control, GitHub environments, Cloudflare, Terraform state, Modal, and SCM App installation + access. +3. The control plane authenticates to Modal with a deployment-wide HMAC secret. +4. Modal trusts possession of that secret for authenticated endpoints and does not receive the + initiating application user, role, or resource grants. + +## Existing Patterns + +### Central authentication composition + +The router attaches a verified principal before authenticated handlers run. Route definitions carry +typed authentication policy, and policy-completeness tests assert that every route declares one. + +### Canonical cross-provider identity + +Browser and bot identities converge on a canonical D1 user while retaining provider identity and +participant identity. Body-supplied identity and credential fields are rejected for +identity-sensitive routes. + +### Session-bound capabilities + +Sandbox tokens, image-build callback tokens, and browser participant WebSocket tokens are scoped to +specific runtime resources rather than functioning as installation-wide human credentials. + +### Provider and scope registries + +Repositories use shared identity helpers, environments have opaque IDs and ordered repository +membership, image builds use explicit repository/environment scope kinds, and integration settings +already resolve global, repository, and environment levels. + +### Attribution without authorization + +Sessions, automations, provider accounts, skills, and logs record creators or actors. Existing code +and design documents explicitly distinguish these fields from ownership checks. + +### Denial and audit behavior + +Authentication failures use `401`; principal-kind failures use `403`. Some sensitive workflows, +including managed skills and Slack notification, emit structured audit logs. There is no complete, +durable application authorization audit ledger. + +## Constraints and Invariants + +- TypeScript and Python use milliseconds and seconds respectively for durations. +- Shared contracts are consumed by control plane, web, and bot packages and are built first. +- D1 is the installation-wide relational store; each Session Durable Object has separate SQLite + state and is not directly joinable with D1 during an in-object operation. +- Route authentication happens before handler execution; handler-authenticated webhooks apply their + own provider or capability checks. +- Browser requests must retain both a signed web-service channel and a valid browser session. +- Bot actors can only be asserted by their owning first-party service namespace. +- Caller-supplied identity fields are rejected where verified principal identity is required. +- Sandbox credentials remain session-bound and session provider-auth choices are immutable after + creation. +- Repository owners may contain nested path segments; repository identity helpers split on the last + slash and preserve the complete owner. +- Environment sessions snapshot repository membership; later environment changes do not alter + existing sessions. +- Secrets are encrypted at rest and values are not returned by list operations, but authorization to + manage their ciphertext and metadata is installation-wide. +- The Modal API receives a deployment credential, not end-user identity; application authorization + currently terminates at the control plane. +- Existing admitted users have broad access under documented single-tenant semantics. + +## Known Gaps and Risks + +- No role, membership, grant, group, workspace, or administrator records exist in D1. +- No authorization action vocabulary or resource-scope vocabulary exists in shared contracts. +- Route policies conflate authentication channel, principal kind, SCM support, and broad route + access; handlers apply resource checks inconsistently. +- `GITHUB_USER_OR_SERVICE_ROUTE` and similar policies often admit all signed services despite their + names. +- Session `owner/member` roles do not define owner-exclusive actions and do not govern most access. +- Session creator, provider-account creator, automation creator, and updater fields can be mistaken + for authorization ownership despite current attribution-only behavior. +- The repository catalog reflects installation authority rather than authenticated-user grants. +- A repository can belong to multiple environments, and environments can contain multiple + repositories; current data has no rules for combining access at those boundaries. +- Bots differ in external authorization evidence. GitHub has repository permission checks in trigger + flows, while Slack and Linear rely primarily on webhook authenticity, configured mappings, and + deployment catalogs. +- Service credentials provide broad route-family capabilities and are not generally constrained by + actor, creator, repository, or session. +- The web exposes navigation and controls before knowing whether an action could be forbidden. +- There is no complete durable record of allow/deny decisions, policy changes, role assignment, or + access revocation. +- Existing tests primarily distinguish authenticated from unauthenticated requests, not multiple + human capability levels or cross-user denial. +- Long-lived sessions, WebSockets, bot mappings, and sandboxes can outlast changes to human access; + current code has no access-revocation lifecycle because access grants do not exist. +- External operator authority is outside the application and cannot be represented by current + principals. + +## Open Questions + +1. Does one Open-Inspect installation correspond permanently to one workspace, or can an + installation contain multiple independently administered organizations? +2. Are application roles intended to be fixed built-in roles, configurable custom roles, or both? +3. Which role bootstraps the first deployment administrator, and how is loss of all administrators + recovered? +4. Are repository permissions inherited solely from an application role, assigned per user/group, + synchronized from SCM, or combined from those sources? +5. Are environments independent authorization resources or derived from access to all, any, or the + primary member repository? +6. Are sessions private to creators by default, visible to users with target access, or visible to + the whole workspace? +7. Which session actions differ among creator, participant owner, participant member, repository + maintainer, and workspace administrator? +8. Does adding a participant grant access, or merely record collaboration after another policy has + admitted access? +9. Do automation runs and child sessions inherit access from the automation owner, triggering actor, + target resource, parent session, or a service identity? +10. Which first-party services may read or mutate installation settings, secrets, provider accounts, + and arbitrary sessions? +11. Do bots act with service-owned capabilities, the asserted human actor's capabilities, or an + intersection of both under the intended product semantics? +12. How are actors without a linked canonical user handled when authorization requires user-level + grants? +13. Is viewing secret key metadata distinct from writing or deleting secret values? +14. Are analytics, user directories, audit records, and usage/cost data separate administrative + capabilities? +15. Which role and grant changes must revoke active WebSockets, bot thread mappings, sandbox access, + or in-flight provider authorization transactions? +16. Which authorization changes require historical audit retention, and for how long? +17. Must existing admitted users preserve their current broad access when role records first appear? +18. Are deployment operators expected to be application administrators, or are these intentionally + separate authority domains? + +## Evidence + +- `packages/control-plane/src/auth/principal.ts`: defines user, service, and sandbox principals and + service actor-namespace rights. +- `packages/control-plane/src/auth/authenticate.ts`: composes signed web-service and browser-session + authentication. +- `packages/control-plane/src/auth/identity-enforcement.ts`: derives actor identity and rejects + caller-supplied identity fields. +- `packages/control-plane/src/auth/user/admission-policy.ts`: defines sign-in admission rules. +- `packages/control-plane/src/db/user-store.ts`: canonicalizes provider identities into users. +- `packages/control-plane/src/routes/shared.ts`: defines route authentication and SCM policies. +- `packages/control-plane/src/router.ts`: attaches principals and enforces principal-kind policies. +- `packages/control-plane/src/db/session-index.ts`: implements installation-wide session visibility. +- `packages/control-plane/src/routes/session-index.ts`: lists and deletes sessions and stores + per-user read state. +- `packages/control-plane/src/routes/session-runtime-proxy.ts`: exposes session runtime actions. +- `packages/control-plane/src/routes/session-ws-token.ts`: mints participant WebSocket credentials. +- `packages/control-plane/src/routes/session-prompt.ts`: derives prompt authors and allows automatic + session participation. +- `packages/control-plane/src/session/schema.ts`: stores Session Durable Object participants and + runtime state. +- `packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts`: checks + participation for selected lifecycle mutations. +- `packages/shared/src/types/sessions.ts`: defines `owner/member` participant roles. +- `packages/web/src/lib/browser-auth-session-contract.ts`: exposes browser user identity without + authorization data. +- `packages/web/src/components/app-auth-boundary.tsx`: gates the application on authentication. +- `packages/web/src/components/session-sidebar.tsx`: exposes shared navigation and All/Mine filters. +- `packages/web/src/components/settings/settings-nav.tsx`: exposes installation settings without + user-role filtering. +- `packages/control-plane/src/routes/repos.ts`: lists repositories using deployment SCM authority. +- `packages/control-plane/src/routes/environments.ts`: exposes installation-wide environment CRUD. +- `packages/control-plane/src/routes/secrets.ts`: exposes global and repository secret management. +- `packages/control-plane/src/routes/environment-secrets.ts`: exposes environment secret management. +- `packages/control-plane/src/routes/integration-settings.ts`: manages global, repository, and + environment settings. +- `packages/control-plane/src/routes/model-provider-accounts.ts`: manages installation-wide provider + accounts with human-only authentication. +- `packages/control-plane/src/routes/automations.ts`: exposes shared automation lifecycle actions. +- `packages/control-plane/src/routes/skills.ts`: separates shared skill administration from per-user + profiles. +- `packages/control-plane/src/routes/mcp-servers.ts`: exposes shared MCP server management. +- `packages/control-plane/src/routes/analytics.ts`: exposes installation-wide analytics. +- `terraform/d1/migrations/0019_create_users.sql`: creates canonical users and attribution columns. +- `terraform/d1/migrations/0033_environments.sql`: creates environments without ownership or grants. +- `terraform/d1/migrations/0055_session_read_states.sql`: creates per-user session read state. +- `docs/HOW_IT_WORKS.md`: documents the single-tenant security and repository-access model. +- `provider-accounts.md`: explicitly treats creator/updater fields as audit metadata and provider + accounts as installation-wide. +- `packages/slack-bot/src/sessions/control-plane-client.ts`: sends signed Slack actor session calls. +- `packages/github-bot/src/handlers.ts`: applies GitHub trigger and sender authorization checks. +- `packages/linear-bot/src/webhook-handler.ts`: resolves Linear actors and session targets. +- `packages/control-plane/src/sandbox/client.ts`: authenticates deployment-wide control-plane calls + to Modal. +- `packages/control-plane/src/router.policy.test.ts`: checks route authentication policy coverage. +- `packages/control-plane/test/integration/ws-token-participants.test.ts`: verifies automatic member + creation. diff --git a/public/docs/internal/2026-08-30-session-access-research.md b/public/docs/internal/2026-08-30-session-access-research.md new file mode 100644 index 000000000..7249aca01 --- /dev/null +++ b/public/docs/internal/2026-08-30-session-access-research.md @@ -0,0 +1,407 @@ +# Research: Session Access and Contribution + +**Date:** 2026-08-30 **Status:** Superseded current-state snapshot **Scope:** Session permission, +relationship, participant, listing, and WebSocket behavior before workspace-wide session +authorization was adopted. + +This document is intentionally research-only. It does not include recommendations, implementation +plans, proposed code/API/schema changes, task breakdowns, estimates, or rollout steps. + +The accepted replacement is +[Workspace-Wide Session Authorization](./2026-08-30-workspace-wide-session-authorization-design.md). + +## Summary + +The current system does not generally require a user to be a session creator or participant before +they can read or contribute to a session. Built-in Members receive `sessions.read.any` and +`sessions.collaborate.any`; Viewers receive `sessions.read.any`. These `any` permissions bypass the +`session_access` relationship table entirely. An unrelated Member can therefore list, read, prompt, +upload collaborative artifacts, and request a WebSocket token for any workspace session. + +`session_access` remains active in narrower workflows. It gates Member lifecycle and sandbox access, +requires creator status for Member deletion and participant management, supports custom roles that +hold only `.own` permissions, filters own-scoped lists, and constrains every actor-backed bot call +because service actors are forced to `own` scope. WebSocket subscription also consults it when the +user's collaboration permission resolves to `.own`. + +The system also has a separate Session Durable Object `participants` table. It stores session-local +identity, SCM metadata, WebSocket tokens, presence identity, and an `owner` or `member` role. It is +not the authority used by `requireSession`, but title, archive, and unarchive still require the +caller to exist in that table. D1 relationships and Durable Object participants can therefore +diverge and have different effects. + +The resulting complexity represents several different concerns under similar terminology rather than +one uniform contribution boundary. + +## Research Questions + +1. Does session access currently restrict who can read or contribute to a session? +2. Which operations still depend on creator or participant relationships? +3. What does `requireSession` enforce for humans, services, and sandboxes? +4. How do D1 `session_access` and Durable Object participants differ? +5. Which current behaviors and documents are inconsistent or ambiguous? + +## Current Behavior + +### Built-in role behavior + +The built-in role registry gives Members these session permissions: + +- `sessions.read.any` +- `sessions.collaborate.any` +- `sessions.lifecycle.own` +- `sessions.participants.manage.own` +- `sessions.delete.own` +- `sessions.sandbox_access.own` + +Viewers receive `sessions.read.any` and no contribution or lifecycle permission. Administrators and +Owners receive the `any` form of every session operation. + +`resolveScopedPermission()` selects `any` before `own`. The router does not query a session +relationship after resolving `any`. + +Consequences for a built-in Member: + +| Operation | Existing relationship required? | Current basis | +| ------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------- | +| List sessions | No | `sessions.read.any` | +| Read session state, messages, artifacts, media, diffs, and children | No | `sessions.read.any` | +| Submit an HTTP prompt | No | `sessions.collaborate.any` | +| Request a WebSocket token | No | `sessions.collaborate.any` | +| Upload attachments, media, or diffs | No | `sessions.collaborate.any` | +| Create a pull request or child session | No prior relationship | `sessions.collaborate.any`, plus operation-specific requirements | +| Stop, rename, archive, unarchive, refresh, or retry | Yes | `sessions.lifecycle.own` | +| Obtain sandbox credentials | Yes | `sessions.sandbox_access.own` | +| Delete a session | Creator only | `sessions.delete.own` | +| Manage participants | Creator only | `sessions.participants.manage.own` | + +An Administrator or Owner bypasses these relationship requirements through the corresponding `*.any` +permission at the router layer. + +### Operation-to-relationship mapping + +`session-authorization-policy.ts` maps each operation to both a permission stem and an own-scope +relationship: + +| Operation | Permission stem | Relationship under `.own` | +| ---------------------- | ------------------------------ | ------------------------- | +| Read | `sessions.read` | Creator or participant | +| Collaborate | `sessions.collaborate` | Creator or participant | +| Lifecycle | `sessions.lifecycle` | Creator or participant | +| Participant management | `sessions.participants.manage` | Creator | +| Sandbox access | `sessions.sandbox_access` | Creator or participant | +| Delete | `sessions.delete` | Creator | + +The term `own` therefore has two meanings in current policy. For four operations it means any access +relationship; for deletion and participant management it means creator. + +### `requireSession` + +`requireSession(operation, sessionIdParam)` creates an active-user route policy with one session +requirement. At request admission, the router: + +1. Loads the effective authorization for the human user or represented service actor. +2. Rejects suspended users and missing role assignments. +3. Resolves the operation's `any` or `own` permission. +4. Applies the signed service's capability ceiling. +5. Forces signed service actors to `own` scope. +6. Queries `session_access` only when the resulting scope is `own`. + +Relationship failures return `session_access_required` or `creator_required` with HTTP 403. +Unexpected authorization storage failures return `authorization_unavailable` with HTTP 503. + +For sandbox-fallback routes, `requireSession` describes the user/service path. A verified sandbox +principal does not have a workspace user authorization and bypasses these RBAC requirements. Its +authority comes from the sandbox token being bound to the route's session ID. + +### D1 `session_access` + +Migration 0071 defines one canonical relationship per session and workspace user: + +```sql +CREATE TABLE session_access ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + relation TEXT NOT NULL CHECK (relation IN ('creator', 'participant')), + PRIMARY KEY (session_id, user_id) +); +``` + +The table contains no activity state, timestamps, invitation source, participant identifier, or +WebSocket state. + +Creator rows are inserted with the D1 session index. Migration 0071 backfills canonical historical +creators. Participant rows are inserted after: + +- successful public WebSocket-token issuance; +- successful public participant addition. + +Participant activation uses `ON CONFLICT DO NOTHING`, so an existing creator row is never downgraded +to participant. + +There is no production participant-removal route or D1 deactivation helper. Relationship deletion +currently occurs through session/user cascade, user merge, test setup, or direct database activity. + +### Session Durable Object participants + +The Session Durable Object has a separate `participants` table containing: + +- a session-local participant ID; +- a provider/session-local `user_id`; +- an optional canonical D1 `canonical_user_id`; +- SCM identity and credentials; +- `owner` or `member` role; +- WebSocket token hash and issuance time; +- join time. + +Session initialization creates an owner participant. WebSocket-token issuance creates or enriches a +member participant. API prompt enqueue also creates a missing participant. + +The DO `owner` or `member` value is not read by `requireSession`. Canonical creator authority comes +from D1 `session_access.relation = 'creator'`. The DO role is returned in participant responses and +persists as session-local state. + +Title, archive, and unarchive differ from other lifecycle routes: after router authorization, their +DO handlers also require the acting identity to exist in the local participants table. Stop, pull +request refresh, diff retry, and child cancellation do not share that second participant-existence +check. + +### Contribution paths + +HTTP prompt admission uses `requireSession("collaborate")`. For a built-in Member this resolves to +`collaborate.any`, so no relationship is required. The DO creates a participant when the prompt +author is not already present, but this prompt path does not create a D1 `session_access` row. + +WebSocket-token issuance also uses `collaborate`. A successful token response creates both a DO +participant and a D1 participant relationship. This means the common browser join flow establishes +the relationship after open collaboration has already authorized the join. + +Once a browser WebSocket subscribes successfully, prompt, cancel, stop, history, typing, and +presence messages use the authenticated client and its authorization lease. Individual WebSocket +commands do not independently resolve read, collaborate, or lifecycle permissions. + +### WebSocket authorization + +The initial WebSocket upgrade verifies only that the session exists. The socket remains +unauthenticated until it sends a subscription token. + +Subscription verifies: + +- the token hash maps to a DO participant; +- the participant has a canonical user ID; +- the canonical user is active and assigned; +- current `sessions.collaborate` permission; +- D1 access when collaboration scope is `.own`; +- the 24-hour token lifetime. + +A successful subscription receives a five-minute authorization lease. During that lease, permission +and relationship changes are not continuously queried. Expiry closes the socket and a later +subscription evaluates current authorization again. + +For the built-in Member's `collaborate.any`, subscription does not require the D1 relationship. For +custom roles with only `collaborate.own`, removing the relationship causes a later subscription to +fail. + +### Lists and displayed capabilities + +Session list and inbox SQL use `sessionAccessPredicate()` only when read scope is `own`. For scope +`any`, the predicate is `1 = 1`. + +Because Member and Viewer use `read.any`, their ordinary lists are workspace-wide. The `Mine` filter +is separate: it filters `sessions.user_id`, which is creator attribution rather than an +authorization relationship. + +At the time of this research, lists also computed `canManageLifecycle` from the caller's lifecycle +scope and relationship. The workspace-wide authorization implementation later removed that response +field; the web client now derives lifecycle-control visibility from current-user permissions, while +lifecycle endpoints perform their own request admission. + +### Services and bots + +Signed services use the represented canonical actor's role, a hard-coded service capability ceiling, +and a forced `own` session scope. A bot actor therefore needs a D1 creator or participant +relationship even when that actor's built-in Member role contains `read.any` and `collaborate.any`. + +This produces a contribution boundary for bot actors that does not exist for browser Members. An +unrelated Slack actor is denied when prompting another actor's session with +`session_access_required`. + +No session route currently declares an actorless service grant. Several bot call sites issue +actorless session requests, including Slack attachment/media operations and Linear stop/event +operations. Central route admission rejects such requests with `service_actor_required` before +session relationship evaluation. + +### Child sessions + +User/service child creation requires `sessions.create` and collaboration on the parent. A parent +sandbox token can create a child through the sandbox capability path without user RBAC. + +The child creator is the parent session's active prompt author. Parent access does not automatically +create child access for a different parent creator. User/service child read and cancellation are +authorized against the child, while the parent sandbox path authenticates against the parent and +then checks parent-child lineage in the handler. + +## Relevant Workflows + +### Browser Member joins an unrelated session + +1. Session list is visible through `sessions.read.any`. +2. Session read is admitted without `session_access`. +3. WebSocket-token request is admitted through `sessions.collaborate.any`. +4. The DO creates or updates a participant and rotates its token. +5. The control plane inserts D1 participant access. +6. Subscription rechecks collaboration and grants a five-minute lease. +7. The participant relationship now satisfies Member lifecycle-own and sandbox-access-own. + +### HTTP prompt without WebSocket token + +1. Prompt request is admitted through `sessions.collaborate.any` for a Member. +2. The DO creates a missing participant and enqueues the prompt. +3. No D1 participant relationship is created by this path. +4. Later lifecycle-own or sandbox-access-own checks still depend on another path having created D1 + access. + +### Actor-backed bot contribution + +1. The service signature identifies the service and represented actor. +2. The actor's current workspace authorization is loaded. +3. The service ceiling is applied. +4. Session scope is forced to `own`. +5. The actor must already have creator or participant D1 access. + +### Administrator lifecycle request without joining + +1. `sessions.lifecycle.any` passes router admission without D1 access. +2. Stop, refresh, and retry can proceed without a DO participant check. +3. Title, archive, and unarchive query the DO participant table and return 403 when the identity is + absent. + +## Existing Patterns + +- Workspace permissions and session relationships are evaluated in the control-plane router. +- The D1 relationship projection uses canonical workspace user IDs. +- The Session DO participant table owns session-local attribution, SCM metadata, tokens, and + connection identity. +- Open collaboration is expressed by built-in `*.any` permissions rather than an exception inside + relationship code. +- Service actors are intentionally narrowed to `own` regardless of their human role's `any` grant. +- Sandbox principals use possession of a session-bound capability instead of workspace RBAC. +- WebSocket authorization is evaluated at subscription and represented by a bounded lease. +- Session list authorization and lifecycle capability are calculated in SQL before results are + returned. + +## Constraints and Invariants + +- One canonical user has at most one D1 relationship per session. +- Creator access is not replaced by participant activation. +- Own-scoped deletion and participant management require creator relation. +- Other own-scoped operations accept creator or participant relation. +- Any-scoped operations do not consult `session_access`. +- Actor-backed services cannot use any-scoped session access. +- A sandbox token is valid only for its bound session route. +- Successful WebSocket subscription requires a canonical user identity. +- WebSocket authorization is bounded by a five-minute lease and token use by a 24-hour lifetime. +- D1 and Session DO writes do not share a cross-store transaction. +- User merge preserves the strongest D1 relationship when creator and participant rows collide. + +## Known Gaps and Risks + +### Relationship and participant divergence + +The two stores have different writers and no reconciliation workflow: + +- API prompt creates a DO participant without D1 access. +- DO success followed by D1 activation failure leaves a DO participant without D1 access. +- D1 user merge rewrites access but does not update existing DO canonical participant identities. +- There is no participant-removal flow spanning D1, DO tokens, presence, or existing sockets. +- DO `owner/member` and D1 `creator/participant` can disagree. + +### Inconsistent lifecycle enforcement + +Title, archive, and unarchive require local DO participant existence after router authorization. +Other lifecycle endpoints do not. This makes `sessions.lifecycle.any` behavior dependent on the +specific endpoint and whether the caller previously joined the session. + +### Contribution does not uniformly establish access + +WebSocket-token contribution establishes D1 participant access; direct HTTP prompting does not. Both +can establish a DO participant. + +### Service-call mismatches + +Some bot call sites omit actors for routes whose central policy requires one. Package-local tests +mock the control plane and do not cover these calls through real central authorization. + +### Documentation drift + +The RBAC design includes mutually inconsistent statements about Member visibility. Its role matrix +describes open Member read/collaboration, while other sections describe Member lists as +creator/participant filtered. It also documents participant removal that is not implemented and +states that the DO has no local owner role even though that field remains in schema and runtime +behavior. + +### Test coverage boundaries + +Existing tests cover scoped permission resolution, relationship checks, list filtering, WebSocket +subscription, service actor isolation, creator-only deletion, and projection writes. No +comprehensive role-by-operation HTTP matrix or end-to-end test of active WebSocket authorization +changes across a lease boundary was found. + +## Open Questions + +1. Is `session_access` intended to represent durable membership, a capability projection, or only + the relationship input for `.own` permissions? +2. Is open Member contribution intended to establish membership, or is the relationship created by + WebSocket-token issuance incidental to the current browser workflow? +3. Is direct HTTP prompt participation intentionally excluded from D1 participant activation? +4. Are the DO participant checks on title, archive, and unarchive intentional authorization or + residual pre-RBAC behavior? +5. Does actor-backed service isolation intentionally differ from open browser Member collaboration? +6. Are DO `owner/member` roles still part of supported session semantics, or only retained state for + compatibility and presentation? +7. Was participant removal deliberately excluded from the current product surface? +8. Is parent-to-child access intentionally independent when the active prompt author differs from + the parent creator? +9. Are the RBAC design documents historical artifacts, living documentation, or a mixture of both? + +## Evidence + +- `packages/shared/src/rbac.ts`: built-in role permission sets and any-before-own scope resolution. +- `packages/control-plane/src/authorization/session-authorization-policy.ts`: + operation-to-permission and operation-to-relationship mapping. +- `packages/control-plane/src/routes/shared.ts`: `requireSession` route metadata construction. +- `packages/control-plane/src/router.ts`: active-user, service-ceiling, scoped-permission, and + relationship enforcement. +- `packages/control-plane/src/db/session-access.ts`: list predicate, exact relationship check, and + participant activation. +- `terraform/d1/migrations/0071_rbac_foundation.sql`: relationship schema, index, and creator + backfill. +- `packages/control-plane/src/db/session-index.ts`: creator insertion, own-scoped listing, and + lifecycle capability projection. +- `packages/control-plane/src/db/session-inbox-store.ts`: inbox visibility and lifecycle capability. +- `packages/control-plane/src/routes/session-ws-token.ts`: public token issuance and D1 participant + activation. +- `packages/control-plane/src/routes/session-prompt.ts`: collaboration admission and + principal-derived prompt identity. +- `packages/control-plane/src/session/message-queue.ts`: prompt-created DO participants. +- `packages/control-plane/src/session/schema.ts`: DO participant schema and owner/member role. +- `packages/control-plane/src/session/connection-authenticator.ts`: WebSocket token, canonical user, + authorization, and token-age checks. +- `packages/control-plane/src/session/websocket-manager.ts`: lease persistence, lookup, and expiry. +- `packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts`: residual DO + participant checks for title/archive/unarchive. +- `packages/control-plane/src/authorization/service-permissions.ts`: bot service capability + ceilings. +- `packages/control-plane/test/integration/rbac-routes.test.ts`: open Member lists and creator-only + deletion. +- `packages/control-plane/test/integration/websocket-client.test.ts`: any/own collaboration, + relationship loss, suspension, and assignment failure behavior. +- `packages/control-plane/test/integration/service-auth.test.ts`: actor-backed service relationship + isolation. +- `packages/control-plane/test/integration/d1-session-index.test.ts`: creator projection, missing + projection, and lifecycle capability behavior. +- `packages/control-plane/test/integration/user-merge.test.ts`: relationship collision precedence. +- `public/docs/internal/2026-08-28-rbac-design.md`: stated RBAC model and observed documentation + contradictions. +- Git commit `69d32c6`: changed Member read and collaboration from own to any while retaining the + relationship projection for narrower operations. diff --git a/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md b/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md new file mode 100644 index 000000000..5934a94d1 --- /dev/null +++ b/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md @@ -0,0 +1,193 @@ +# Design: Workspace-Wide Session Authorization + +**Date:** 2026-08-30 + +**Status:** Accepted + +**Research:** [2026-08-30-session-access-research.md](./2026-08-30-session-access-research.md) + +## Summary + +Open-Inspect sessions are workspace-wide resources. An active user may perform an operation on every +session when their workspace role grants that operation. Session creator and participant +relationships do not grant, narrow, or revoke authorization. + +Session authorization uses unscoped operation permissions. Actor-backed bot requests intersect the +represented user's current role with the bot service's fixed capability ceiling, without applying a +session relationship check. + +Creator attribution, participant identity, sandbox capability binding, and WebSocket authorization +remain supported concerns, but none is a session access-control list. + +## Context + +Before workspace RBAC, authenticated users could operate across sessions without a creator or +participant authorization boundary. The RBAC foundation introduced `.own` and `.any` session +permission pairs and a D1 `session_access` projection. Built-in Members still received +workspace-wide read and collaboration, while lifecycle, sandbox access, deletion, participant +management, and bot requests became relationship-dependent. + +That partial relationship model does not match the product's multiplayer behavior. It also creates +two inconsistent participant stores: D1 relationships used for authorization and Session Durable +Object participants used for message identity, presence, SCM metadata, and WebSocket tokens. +Different contribution paths update those stores differently. + +## Decisions + +### Workspace-wide operations + +Session permissions are operation permissions without resource scope: + +- `sessions.read` +- `sessions.collaborate` +- `sessions.create` +- `sessions.lifecycle` +- `sessions.sandbox_access` +- `sessions.delete` + +A granted session operation applies to every session in the workspace. No route or WebSocket +authorization check consults creator or participant relationships. + +Deletion is workspace-scoped. Creator-only deletion is explicitly deferred and is not part of this +RBAC change. + +### Built-in roles + +Built-in roles distinguish which operations a user may perform, not which sessions they may target: + +| Role | Session behavior | +| ------------- | ----------------------------------------------------------------------------------- | +| Owner | Every session operation across the workspace. | +| Administrator | Every session operation across the workspace. | +| Member | Create, read, collaborate, manage lifecycle, access sandboxes, and delete sessions. | +| Viewer | Read every session; no create, collaborate, lifecycle, sandbox, or delete access. | + +Custom roles may contain any registered session operation permission. Custom roles cannot express +private, invitation-only, creator-only, or participant-only session access. + +### Actor-backed services + +A bot service acting for a human uses the intersection of two operation sets: + +```text +effective operations = actor role permissions intersect service capability ceiling +``` + +The represented actor must resolve to an active canonical workspace user. The service cannot exceed +the actor's role or its own ceiling. If both grant `sessions.collaborate`, the actor may collaborate +on any session, including a session created by another user. This preserves multiplayer Slack, +GitHub, and Linear workflows. + +Actorless service calls remain limited to narrow route-specific grants. + +### Creator attribution + +`sessions.user_id` records the canonical user responsible for creating a session. It supports +display, filtering, auditing, credential selection, automation lineage, and other attribution needs. +It is not an authorization relationship. + +The `Mine` session-list filter continues to select sessions by creator attribution. It is a user +filter, not an access boundary. + +### Participant identity + +Session Durable Object participants identify message authors and connected clients. They may retain: + +- provider identity and canonical user linkage; +- display and SCM metadata; +- message attribution; +- presence identity; +- WebSocket token ownership. + +Participant existence and the persisted `owner` or `member` value do not authorize session +operations. Joining or contributing to a session does not create a separate authorization grant. + +Participant-management APIs that exist only to maintain access-control relationships are removed. +Runtime participant creation required for attribution remains internal to contribution and +WebSocket-token flows. + +### WebSockets + +WebSocket token issuance and subscription require an active canonical user with +`sessions.collaborate`. Tokens remain bound to their session and participant identity. Subscription +authorization is rechecked through bounded leases so suspension or role changes affect live access. + +The authorization recheck evaluates active workspace membership and `sessions.collaborate`; it does +not evaluate creator or participant access records. + +### Sandbox capabilities + +Human or actor-backed requests for sandbox credentials require `sessions.sandbox_access`, which +applies workspace-wide. Sandbox-originated control-plane requests continue to authenticate with a +session-bound sandbox capability and remain restricted to that session. + +Human workspace authorization and sandbox capability binding are separate security boundaries. + +### Lifecycle and state checks + +Lifecycle routes require `sessions.lifecycle` for every session. Session state-machine checks, +queued-work checks, and sandbox runtime constraints continue to apply. + +Durable Object participant existence is not a lifecycle authorization condition. Rename, archive, +and unarchive follow the same workspace permission policy as stop, retry, and refresh. + +### Service and UI metadata + +Session lists are not filtered by authorization relationships. Query filters such as creator and +status remain supported. + +The web client derives lifecycle-control visibility from the current user's workspace +`sessions.lifecycle` permission. Session list and inbox responses contain session data, not +authorization presentation metadata; lifecycle endpoints remain authoritative. + +## Removed Model + +The RBAC foundation does not include: + +- a D1 `session_access` table; +- creator or participant authorization projections; +- `.own` and `.any` session permission pairs; +- relationship-filtered session or inbox queries; +- relationship activation during WebSocket token issuance; +- relationship-aware user merge behavior; +- creator-only deletion or participant management; +- bot-specific narrowing to sessions associated with the represented actor. + +Because this schema and permission model were introduced on the unshipped RBAC branch, they are +removed directly from the branch migration and permission registry rather than retained as a +compatibility layer. + +## Deferred Features + +Private, invitation-only, creator-restricted, or participant-restricted sessions require a separate +product design. Such a design must address visibility, invitations, removal, revocation, historical +participants, bot behavior, parent-child sessions, cross-store consistency, migration, and UI. + +No relationship schema or permission identifiers are retained speculatively for that future work. + +## Invariants + +- A workspace permission has the same meaning for browser users and represented bot actors. +- A service may narrow an actor's operations but may not expand them. +- Session creator and participant data are attribution and runtime identity, not authorization. +- Every user with `sessions.read` can read and list every session. +- Every user with `sessions.collaborate` can contribute to every session. +- Every user with `sessions.lifecycle` can invoke lifecycle operations on every session. +- Every user with `sessions.sandbox_access` can request sandbox access for every session. +- Every user with `sessions.delete` can delete every session. +- Sandbox credentials remain bound to one session regardless of human workspace permissions. +- Suspension and role changes apply to new HTTP requests and bounded-lifetime WebSocket leases. + +## Verification + +The implementation must cover: + +- a role-by-operation HTTP authorization matrix; +- cross-user browser collaboration; +- cross-user actor-backed bot listing and collaboration; +- service ceiling denial when the actor role permits an operation the service does not; +- Viewer read access and mutation denial; +- workspace-wide lifecycle, sandbox, and deletion behavior for permitted roles; +- WebSocket subscription reauthorization after role or suspension changes; +- session-bound sandbox authentication; +- lifecycle consistency across rename, archive, unarchive, stop, retry, and refresh. From ade164732018ce2e62e5e16424918a4a3ddbbdf0 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Sun, 30 Aug 2026 22:03:51 -0700 Subject: [PATCH 07/11] chore: preserve original RBAC patch bytes --- packages/control-plane/src/auth/user/better-auth.ts | 1 - packages/control-plane/src/db/session-inbox-store.ts | 4 ++++ packages/control-plane/src/db/user-store.ts | 2 +- packages/shared/src/types/session-inbox.ts | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/control-plane/src/auth/user/better-auth.ts b/packages/control-plane/src/auth/user/better-auth.ts index ab082bd19..c9185bd46 100644 --- a/packages/control-plane/src/auth/user/better-auth.ts +++ b/packages/control-plane/src/auth/user/better-auth.ts @@ -6,7 +6,6 @@ import { generateId } from "../crypto"; import type { ProviderProfileResolver } from "./provider-profile"; const MS_PER_SECOND = 1000; - export const SESSION_EXPIRES_IN_MS = 7 * 24 * 60 * 60 * MS_PER_SECOND; export const SESSION_UPDATE_AGE_MS = 24 * 60 * 60 * MS_PER_SECOND; diff --git a/packages/control-plane/src/db/session-inbox-store.ts b/packages/control-plane/src/db/session-inbox-store.ts index 3574640b3..9eafe0e3d 100644 --- a/packages/control-plane/src/db/session-inbox-store.ts +++ b/packages/control-plane/src/db/session-inbox-store.ts @@ -9,6 +9,7 @@ import type { SessionInboxCursor } from "./session-inbox-cursor"; import { readStateFromRow, unreadSql, type ViewerReadStateRow } from "./session-read-state"; import type { SqlDatabase, SqlStatement } from "./sql-database"; +/** Viewer, filtering, and pagination inputs for an inbox query. */ export interface ListSessionInboxOptions { category: SessionInboxCategory; createdByUserIds?: readonly string[]; @@ -69,9 +70,11 @@ function toListItem(row: InboxSessionRow): SessionListItem { }; } +/** Builds viewer-specific session inbox pages from the D1 session index. */ export class SessionInboxStore { constructor(private readonly db: SqlDatabase) {} + /** List one inbox category with viewer-specific read state. */ async list(options: ListSessionInboxOptions): Promise { const result = await this.bindInboxQuery(options).all(); const page = this.buildPageData(options.limit, result.results ?? []); @@ -85,6 +88,7 @@ export class SessionInboxStore { ); } + /** List every inbox category with viewer-specific read state. */ async snapshot( options: Omit ): Promise { diff --git a/packages/control-plane/src/db/user-store.ts b/packages/control-plane/src/db/user-store.ts index c889748be..3cd1d0c23 100644 --- a/packages/control-plane/src/db/user-store.ts +++ b/packages/control-plane/src/db/user-store.ts @@ -166,7 +166,7 @@ export class UserStore { return await this.doResolveOrCreate(identity); } catch (err) { if (isUniqueConstraintError(err)) { - return await this.doResolveOrCreate(identity); + return this.doResolveOrCreate(identity); } throw err; } diff --git a/packages/shared/src/types/session-inbox.ts b/packages/shared/src/types/session-inbox.ts index 5289f92f2..5e6c3f45b 100644 --- a/packages/shared/src/types/session-inbox.ts +++ b/packages/shared/src/types/session-inbox.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { PullRequestSummary, SessionReadState, SessionStatus, SpawnSource } from "./sessions"; import type { SessionListRepository } from "./repositories"; +/** Viewer-specific session row returned by list and inbox APIs. */ export interface SessionListItem { id: string; title: string | null; From 4866d41a315772efbaf9f503104daf18738a5506 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Sun, 30 Aug 2026 22:34:24 -0700 Subject: [PATCH 08/11] 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 09/11] 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 82f72644d5534d54693cd51e0888d25c6c097eb9 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Sun, 30 Aug 2026 21:56:45 -0700 Subject: [PATCH 10/11] feat: gate session and automation UI by permission --- README.md | 8 +- docs/AUTH.md | 206 +++++ docs/GETTING_STARTED.md | 62 +- .../automations/[id]/edit/page.test.tsx | 92 ++ .../(sidebar)/automations/[id]/edit/page.tsx | 18 +- .../(sidebar)/automations/[id]/page.test.tsx | 107 +++ .../(app)/(sidebar)/automations/[id]/page.tsx | 106 ++- .../(sidebar)/automations/new/page.test.tsx | 21 +- .../(app)/(sidebar)/automations/new/page.tsx | 11 +- .../(app)/(sidebar)/automations/page.test.tsx | 29 +- .../app/(app)/(sidebar)/automations/page.tsx | 27 +- .../automations/templates/page.test.tsx | 54 ++ .../(sidebar)/automations/templates/page.tsx | 12 + .../web/src/app/(app)/(sidebar)/page.test.tsx | 20 + packages/web/src/app/(app)/(sidebar)/page.tsx | 16 +- .../app/(app)/(sidebar)/session/[id]/page.tsx | 109 ++- .../web/src/components/action-bar.test.tsx | 14 + packages/web/src/components/action-bar.tsx | 35 +- .../automations/automations-list.test.tsx | 81 +- .../automations/automations-list.tsx | 229 ++--- .../web/src/components/diff-retry-notice.tsx | 26 +- .../src/components/mobile-session-actions.tsx | 31 +- .../components/queued-prompt-stack.test.tsx | 13 + .../src/components/queued-prompt-stack.tsx | 24 +- .../web/src/components/session-actions.ts | 1 + .../src/components/session-changes-panel.tsx | 9 +- .../components/session-details-overlay.tsx | 6 + .../src/components/session-header.test.tsx | 30 + .../web/src/components/session-header.tsx | 18 +- .../src/components/session-list-item.test.tsx | 95 ++ .../web/src/components/session-list-item.tsx | 115 +-- .../components/session-prompt-composer.tsx | 2 + .../components/session-right-sidebar.test.tsx | 39 + .../src/components/session-right-sidebar.tsx | 38 +- .../web/src/components/session-sidebar.tsx | 1 + .../src/components/sidebar-layout.test.tsx | 22 + .../web/src/components/sidebar-layout.tsx | 9 +- .../components/sidebar/metadata-section.tsx | 4 +- .../src/hooks/use-global-shortcuts.test.tsx | 37 +- .../web/src/hooks/use-global-shortcuts.ts | 6 +- packages/web/src/hooks/use-sandbox-access.ts | 9 +- .../web/src/hooks/use-session-socket.test.tsx | 22 + packages/web/src/hooks/use-session-socket.ts | 24 +- .../src/hooks/use-session-transport.test.tsx | 19 + .../web/src/hooks/use-session-transport.ts | 28 +- .../src/lib/automation-authorization.test.ts | 45 + .../web/src/lib/automation-authorization.ts | 17 + .../docs/internal/2026-08-28-rbac-design.md | 815 ++++++++++++++++++ .../docs/internal/2026-08-28-rbac-research.md | 386 +++++++++ .../2026-08-30-session-access-research.md | 407 +++++++++ ...space-wide-session-authorization-design.md | 193 +++++ 51 files changed, 3391 insertions(+), 357 deletions(-) create mode 100644 docs/AUTH.md create mode 100644 packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx create mode 100644 packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx create mode 100644 packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx create mode 100644 packages/web/src/components/session-list-item.test.tsx create mode 100644 packages/web/src/lib/automation-authorization.test.ts create mode 100644 packages/web/src/lib/automation-authorization.ts create mode 100644 public/docs/internal/2026-08-28-rbac-design.md create mode 100644 public/docs/internal/2026-08-28-rbac-research.md create mode 100644 public/docs/internal/2026-08-30-session-access-research.md create mode 100644 public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md diff --git a/README.md b/README.md index 304c79a66..58c915e48 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ The system uses a shared GitHub App installation for git operations (clone, fetc control plane mints short-lived installation tokens server-side and brokers them to sandboxes through the git credential helper on demand. This means: -- **All users share the same GitHub App credentials** - The GitHub App must be installed on your - organization's repositories, and any user of the system can access any repo the App has access to +- **Authorized users share the same GitHub App credentials** - The GitHub App must be installed on + your organization's repositories, and active users whose role permits repository use can access + any repo the App has access to - **No per-user repository access validation** - The system does not verify that a user has permission to access a specific repository before creating a session - **GitHub users' OAuth tokens are used for PR creation** - For GitHub logins, PRs are created using @@ -70,6 +71,9 @@ built for internal use where all employees are trusted and have access to compan 4. **Use GitHub's repository selection** - When installing the App, select specific repositories rather than "All repositories" +See [Authentication and Authorization](docs/AUTH.md) for workspace roles, session access, automation +ownership, bots, and member suspension. + ## Architecture ``` diff --git a/docs/AUTH.md b/docs/AUTH.md new file mode 100644 index 000000000..e8d9f4fc8 --- /dev/null +++ b/docs/AUTH.md @@ -0,0 +1,206 @@ +# Authentication and Authorization + +Open-Inspect uses authentication to establish who you are and workspace authorization to decide what +you can do. This guide explains the behavior users and workspace administrators will see. + +> **Important:** Open-Inspect is designed for a single trusted organization. A deployment is one +> workspace, and the source-control App installation defines the repositories available to that +> workspace. Roles control which Open-Inspect features a person can use; they are not per-repository +> access lists. + +--- + +## Signing In + +A deployment can offer GitHub sign-in, Google sign-in, or both. The sign-in page shows only the +providers configured by the deployment operator. + +Signing in has two stages: + +1. Your identity provider verifies your identity and email address. +2. The deployment's admission rules determine whether you may join the workspace. + +Depending on the deployment configuration, admission can be limited by: + +- GitHub username +- Verified email address +- Verified email domain +- Active membership in an allowed GitHub organization + +These rules are checked when you sign in. Removing someone from an allowlist or GitHub organization +does not end an existing browser session; an Administrator or Owner can suspend the member when +access must be revoked immediately. + +Authentication does not make someone an Owner or Administrator. Every admitted user has exactly one +workspace role, and new users receive the Member role by default. + +## Workspace Roles + +Open-Inspect includes four built-in roles. + +| Capability | Owner | Administrator | Member | Viewer | +| ------------------------------------------------- | :---: | :-----------: | :----: | :----: | +| View repositories and environments | Yes | Yes | Yes | Yes | +| Use repositories and environments in sessions | Yes | Yes | Yes | No | +| Manage shared settings, integrations, and secrets | Yes | Yes | No | No | +| Create sessions | Yes | Yes | Yes | No | +| View every session | Yes | Yes | Yes | Yes | +| Collaborate in and manage sessions | Yes | Yes | Yes | No | +| View automations | Yes | Yes | Yes | Yes | +| Create automations | Yes | Yes | Yes | No | +| Manage and trigger own automations | Yes | Yes | Yes | No | +| Manage and trigger any automation | Yes | Yes | No | No | +| View and manage workspace members | Yes | Yes | No | No | +| Transfer workspace ownership | Yes | No | No | No | +| View analytics | Yes | Yes | Yes | Yes | +| View provider accounts | Yes | Yes | Yes | No | +| View image-build history | Yes | Yes | Yes | Yes | +| Manage personal skill profiles | Yes | Yes | Yes | No | + +### Owner + +Owners have full access to the workspace. Only Owners can grant or remove the Owner role or suspend +and restore another Owner. Open-Inspect also prevents the final active Owner from being suspended or +demoted, so the workspace cannot accidentally lose all ownership. + +### Administrator + +Administrators can operate the workspace day to day. They can manage members, sessions, automations, +repositories, environments, provider accounts, integrations, and secrets. They cannot transfer +ownership, change who holds the Owner role, or suspend and restore an Owner. + +### Member + +Members can create and use sessions, collaborate in existing sessions, use shared repositories and +environments, and create automations. They can manage and manually trigger automations they own but +cannot modify another person's automation or administer shared configuration. They can view +workspace analytics. + +### Viewer + +Viewers have read-only access to shared workspace resources. They can inspect sessions, automations, +analytics, repositories, environments, skills, and MCP servers. They cannot create or prompt +sessions, access sandboxes, manage personal skill profiles, trigger automations, or change shared +configuration. + +## How Session Access Works + +Sessions are workspace resources rather than private resources owned by their creator. + +- Anyone with session read access can view every session in the workspace. +- Anyone with collaboration access can prompt and contribute to every session. +- Anyone with lifecycle access can stop, retry, archive, unarchive, and otherwise manage every + session. +- Anyone with sandbox access can use supported sandbox tools for every session. +- Anyone with delete access can delete every session. + +The creator shown on a session records attribution; it is not an access list. Likewise, participant +labels identify who contributed to a session but do not grant or remove workspace permissions. The +**Mine** filter is a convenience for finding sessions you created, not a security boundary. + +Creating a session also requires permission to use its selected repository or environment. A role +may therefore be able to view an existing session without being allowed to create a new one. + +New HTTP requests reflect role changes and suspension immediately. Live browser connections to a +session are rechecked at least every five minutes, so a connection may remain open for up to five +minutes after access changes. Recreating the session is not required. + +## How Automation Access Works + +Automation definitions and run history are visible workspace-wide to roles with automation read +access. Creating, changing, and manually triggering automations use ownership rules. + +- Members can manage and manually trigger automations they own. +- Administrators and Owners can manage and manually trigger any automation. +- Viewers can inspect automations but cannot create, change, or run them. + +Automation ownership follows the signed-in account that created it, not a display name or external +provider username. + +### Scheduled and Event Runs + +Scheduled and event-driven runs execute under the automation owner's authority. At run time, the +owner must still be active and allowed to create sessions and use every selected repository or +environment. If those permissions have been removed, the run does not start. + +### Manual Runs + +A manual run executes under the authority of the person who clicked **Run**, even when an +Administrator or Owner triggers someone else's automation. The requester must be allowed both to +trigger that automation and to create the resulting session with its selected resources. Their +identity and linked source-control credentials are used for that run. + +See [Automations](AUTOMATIONS.md) for trigger setup and run behavior. + +## Bots and Integrations + +Slack, GitHub, and Linear integrations act on behalf of a workspace user when they handle a user +request. Their effective access is limited by both: + +- The acting user's current role +- The integration's fixed set of allowed operations + +This means an integration cannot bypass a suspended user or perform workspace administration simply +because the acting user is an Owner. Calls that do not identify an acting user are denied unless a +specific integration route explicitly permits that operation. + +Some integrations also apply their own ingress rules. For example, the GitHub integration may +require an allowed trigger user or sufficient repository collaborator access before it sends a +request to Open-Inspect. + +## Suspension + +Suspending a member disables their workspace access without deleting their account or historical +attribution. + +After suspension: + +- New browser and bot operations are denied. +- Existing browser sign-in sessions are invalidated. +- Live browser session connections close within five minutes. +- Scheduled and event-driven automations owned by the member no longer pass run authorization. +- Existing session history and authorship remain intact. + +Suspension does not automatically stop a sandbox that is already executing. An Administrator or +Owner can manage that session separately. + +## Repository and Credential Boundaries + +Open-Inspect uses a shared source-control App installation for clone, fetch, and push operations. +The App should be installed only on repositories intended for the workspace. + +A user's role determines whether they may read or use workspace repositories, but Open-Inspect does +not compare that role with the user's personal GitHub access for each repository. Linked GitHub +credentials can be used for actions such as attributed pull-request creation; when no suitable user +credential is available, supported operations may use the shared App identity. + +Secrets and provider credentials are not made visible through role-based read access. Administrative +permissions control who can configure them, and saved secret values are not returned to the browser. +See [Secrets Management](SECRETS.md) for details. + +## Workspace Administration + +Owners and Administrators can manage members from **Settings > Workspace access**. Depending on +their own role, they can: + +- Review workspace members and assigned roles +- Change a member's role +- Suspend or restore a member + +Only an Owner can assign or remove the Owner role or suspend and restore another Owner. The final +active Owner cannot be suspended or demoted. + +### Initial Owner Setup + +The first person who signs in receives the default Member role and is not promoted to Owner +automatically. On a new deployment, the intended Owner must sign in once, after which a deployment +operator runs the Owner bootstrap command using that person's Open-Inspect user ID. See +[Getting Started](GETTING_STARTED.md#step-7a-bootstrap-the-workspace-owner) for the deployment +steps. + +## Related Guides + +- [Getting Started](GETTING_STARTED.md) +- [Automations](AUTOMATIONS.md) +- [Secrets Management](SECRETS.md) +- [How Open-Inspect Works](HOW_IT_WORKS.md) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index d9c2402dc..9600c52d6 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -302,10 +302,11 @@ GitHub OAuth sign-in, but its client pair is optional when Google is the only si > **Keep "User-to-server token expiration" active** (GitHub App → **Optional Features**; it is > the default for newly created Apps, but activate it if yours predates that default). Expiring > user tokens are what make GitHub return a **refresh token** at sign-in, and Open-Inspect stores - > that per-user credential so sessions clone, commit, and push **as the signed-in user**. With - > expiration deactivated — or on an **OAuth App**, which never issues a refresh token — no - > per-user credential is captured and sessions fall back to the shared GitHub App **bot** - > identity for repository access. + > that per-user credential for attributed GitHub operations such as pull-request creation. Clone, + > fetch, and push authentication still use the shared GitHub App installation. With expiration + > deactivated — or on an **OAuth App**, which never issues a refresh token — no per-user + > credential is captured, so supported attributed operations fall back to the shared GitHub App + > **bot** identity. 5. Set **Repository permissions**: - Actions: **Read-only** _(required for GitHub workflow-run automations)_ @@ -651,10 +652,9 @@ configurations because they authorize repository operations; they do not enable ### Enable Google Login (Optional) -Google login lets non-developer users (PMs, support agents) sign in without a GitHub account. They -get the same flat access as everyone else; git operations still use the shared GitHub App, and their -PRs fall back to the App bot (no personal GitHub attribution unless the same verified email is also -a linked GitHub identity). +Google login lets non-developer users (PMs, support agents) sign in without a GitHub account. Git +operations still use the shared GitHub App, and their PRs fall back to the App bot (no personal +GitHub attribution unless the same verified email is also a linked GitHub identity). 1. In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), create an **OAuth client ID** of type **Web application**. @@ -726,6 +726,52 @@ Terraform will update the workers with the required bindings. --- +## Step 7a: Bootstrap the Workspace Owner + +Owner assignment is an explicit operator action. After both deployment phases complete: + +1. Have the intended Owner sign in to the deployed web application once. This creates their + canonical user and default role assignment. +2. While signed in, open `/api/auth/get-session` on the web application origin and record the + 32-character lowercase hexadecimal `user.id`. The bootstrap command accepts this canonical ID, + never an email address. +3. Obtain the D1 database name with `terraform output -raw d1_database_name` from + `terraform/environments/production`. +4. From the repository root, run the remote dry run (the default): + +```bash +npm run rbac:bootstrap-owner -- \ + --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \ + --user "" +``` + +5. Confirm the preflight result is `ready` (or `no-op` when the target is already the current + unsuspended Owner), then execute the same command with `--execute`: + +```bash +npm run rbac:bootstrap-owner -- \ + --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \ + --user "" \ + --execute +``` + +The command uses Wrangler credentials (`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, or +`wrangler login`) and targets remote D1. It refuses a suspended/missing user, a missing or ambiguous +assignment, or another unsuspended Owner. There is no force option. Execution is one atomic Wrangler +SQL file: it writes one redacted `workspace.owner_bootstrapped` service audit event and replaces the +target's assignment. A no-op writes nothing. + +6. Verify the control-plane health response contains `"rbac":{"ownerAssignment":"present"}`: + +```bash +curl "$(terraform -chdir=terraform/environments/production output -raw control_plane_url)/health" +``` + +This health value reports current state: `present` means at least one Owner assignment belongs to an +unsuspended user. + +--- + ## Step 7b: Complete Slack Setup (If Using Slack) Now that the Slack bot worker is deployed, configure the agent experience, App Home, and event diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx new file mode 100644 index 000000000..cf08e8764 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +/// + +import { Suspense } from "react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import EditAutomationPage from "./page"; + +expect.extend(matchers); + +const CURRENT_USER_ID = "11111111111111111111111111111111"; +let permissions: string[] = []; +const replace = vi.fn(); + +const automation = { + id: "auto-1", + name: "Nightly review", + instructions: "Review the code", + triggerType: "schedule" as const, + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: null, + enabled: true, + nextRunAt: null, + consecutiveFailures: 0, + createdBy: CURRENT_USER_ID, + userId: "22222222222222222222222222222222", + createdAt: 1, + updatedAt: 1, + deletedAt: null, + eventType: null, + triggerConfig: null, + repositories: [], + environmentIds: [], + providerSelections: {}, +}; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace }), +})); +vi.mock("@/components/sidebar-layout", () => ({ + CollapsedSidebarControls: () => null, + useSidebarContext: () => ({ isOpen: true }), +})); +vi.mock("@/hooks/use-automations", () => ({ + useAutomation: () => ({ automation, loading: false }), +})); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + authorization: { userId: CURRENT_USER_ID, permissions }, + loading: false, + }), +})); +vi.mock("@/components/automations/automation-form", () => ({ + AutomationForm: () =>
Automation edit form
, +})); + +async function renderPage() { + await act(async () => { + render( + + + + ); + }); +} + +beforeEach(() => { + permissions = []; + replace.mockReset(); +}); +afterEach(cleanup); + +describe("EditAutomationPage authorization", () => { + it("redirects an unauthorized own-scoped deep link without rendering the form", async () => { + permissions = ["automations.manage.own"]; + await renderPage(); + + await waitFor(() => expect(replace).toHaveBeenCalledWith("/automations/auto-1")); + expect(screen.queryByText("Automation edit form")).not.toBeInTheDocument(); + }); + + it("renders the form with automations.manage.any", async () => { + permissions = ["automations.manage.any"]; + await renderPage(); + + expect(await screen.findByText("Automation edit form")).toBeInTheDocument(); + expect(replace).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx index 2224ac53f..0443a31ae 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, use } from "react"; +import { useEffect, useState, use } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; @@ -12,14 +12,26 @@ import { import { ErrorBanner } from "@/components/ui/error-banner"; import { BackIcon } from "@/components/ui/icons"; import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { canAccessAutomation } from "@/lib/automation-authorization"; export default function EditAutomationPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const { isOpen } = useSidebarContext(); const router = useRouter(); const { automation, loading } = useAutomation(id); + const { authorization, loading: authorizationLoading } = useCurrentUserAuthorization(); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); + const canManage = automation + ? canAccessAutomation("automations.manage", authorization, automation) + : false; + + useEffect(() => { + if (!loading && !authorizationLoading && automation && !canManage) { + router.replace(`/automations/${id}`); + } + }, [automation, authorizationLoading, canManage, id, loading, router]); const handleSubmit = async (values: AutomationFormValues) => { setSubmitting(true); @@ -45,7 +57,7 @@ export default function EditAutomationPage({ params }: { params: Promise<{ id: s } }; - if (loading) { + if (loading || authorizationLoading) { return (
@@ -66,6 +78,8 @@ export default function EditAutomationPage({ params }: { params: Promise<{ id: s ); } + if (!canManage) return null; + return (
{!isOpen && ( diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx new file mode 100644 index 000000000..a877f4b02 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +/// + +import { Suspense } from "react"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AutomationDetailPage from "./page"; + +expect.extend(matchers); + +const CURRENT_USER_ID = "11111111111111111111111111111111"; +const OTHER_USER_ID = "22222222222222222222222222222222"; +let permissions: string[] = []; + +const automation = { + id: "auto-1", + name: "Nightly review", + instructions: "Review the code", + triggerType: "schedule" as const, + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: null, + enabled: true, + nextRunAt: null, + consecutiveFailures: 0, + createdBy: CURRENT_USER_ID, + userId: OTHER_USER_ID, + createdAt: 1, + updatedAt: 1, + deletedAt: null, + eventType: null, + triggerConfig: null, + repositories: [], + environmentIds: [], + providerSelections: {}, +}; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock("next/link", () => ({ + default: ({ children, ...props }: React.ComponentProps<"a">) => {children}, +})); +vi.mock("@/components/sidebar-layout", () => ({ + CollapsedSidebarControls: () => null, + useSidebarContext: () => ({ isOpen: true }), +})); +vi.mock("@/hooks/use-automations", () => ({ + useAutomation: () => ({ automation, loading: false, mutate: vi.fn() }), + useAutomationInvocations: () => ({ + invocations: [], + total: 0, + loading: false, + mutate: vi.fn(), + }), +})); +vi.mock("@/hooks/use-environments", () => ({ + useEnvironments: () => ({ environments: [] }), +})); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + authorization: { + userId: CURRENT_USER_ID, + permissions, + }, + }), +})); +vi.mock("@/components/automations/run-history", () => ({ RunHistory: () => null })); + +async function renderPage() { + await act(async () => { + render( + + + + ); + }); +} + +beforeEach(() => { + permissions = []; +}); +afterEach(cleanup); + +describe("AutomationDetailPage authorization", () => { + it("does not treat createdBy provenance as canonical ownership", async () => { + permissions = ["automations.manage.own", "automations.trigger.own"]; + await renderPage(); + await screen.findByRole("heading", { name: "Nightly review" }); + + expect(screen.queryByRole("link", { name: /edit/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Trigger Now" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Pause" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); + }); + + it("shows manage and trigger controls with any-scoped capabilities", async () => { + permissions = ["automations.manage.any", "automations.trigger.any"]; + await renderPage(); + await screen.findByRole("heading", { name: "Nightly review" }); + + expect(screen.getByRole("link", { name: /edit/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Trigger Now" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx index 49bc584ec..bd718936c 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx @@ -17,6 +17,8 @@ import { BackIcon, PencilIcon } from "@/components/ui/icons"; import { formatModelNameLower } from "@/lib/format"; import { formatAutomationTargetsLabel } from "@/lib/repo-label"; import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { canAccessAutomation } from "@/lib/automation-authorization"; const HISTORY_PAGE_SIZE = 20; @@ -25,6 +27,7 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: const { isOpen } = useSidebarContext(); const router = useRouter(); const { automation, loading, mutate } = useAutomation(id); + const { authorization } = useCurrentUserAuthorization(); const { environments } = useEnvironments(); // "Load more" grows the fetch limit rather than paging by offset: the // endpoint returns newest-first, so a larger limit re-fetches the head plus @@ -96,6 +99,9 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: ); } + const canManage = canAccessAutomation("automations.manage", authorization, automation); + const canTrigger = canAccessAutomation("automations.trigger", authorization, automation); + return (
{!isOpen && ( @@ -139,70 +145,76 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id:

- - - - - {automation.enabled ? ( - - ) : ( + {canManage && ( + + + + )} + {canTrigger && ( )} - {confirmDelete ? ( -
+ {canManage && + (automation.enabled ? ( + ) : ( -
- ) : ( - - )} + ))} + {canManage && + (confirmDelete ? ( +
+ + +
+ ) : ( + + ))}
diff --git a/packages/web/src/app/(app)/(sidebar)/automations/new/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/new/page.test.tsx index 6ee14e82b..f237d7dc1 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/new/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/new/page.test.tsx @@ -14,10 +14,19 @@ afterEach(cleanup); // Mutable per-test inputs (vi.mock factories are hoisted, so they close over these). let search = ""; let enabledModelsValue: string[] = [DEFAULT_MODEL, "anthropic/claude-opus-4-8", "openai/gpt-5.5"]; +let canCreate = true; +const replace = vi.fn(); vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(search), - useRouter: () => ({ push: vi.fn() }), + useRouter: () => ({ push: vi.fn(), replace }), +})); + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => permission === "automations.create" && canCreate, + loading: false, + }), })); vi.mock("@/components/sidebar-layout", () => ({ @@ -58,9 +67,19 @@ vi.mock("@/components/ui/combobox", () => ({ beforeEach(() => { search = ""; enabledModelsValue = [DEFAULT_MODEL, "anthropic/claude-opus-4-8", "openai/gpt-5.5"]; + canCreate = true; + replace.mockReset(); }); describe("NewAutomationPage template pre-fill", () => { + it("redirects a direct create link without automations.create", () => { + canCreate = false; + render(); + + expect(replace).toHaveBeenCalledWith("/automations"); + expect(screen.queryByRole("heading", { name: "Create Automation" })).not.toBeInTheDocument(); + }); + it("pre-fills the form from a known template and leaves the repository empty", () => { search = "template=find-bugs"; render(); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/new/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/new/page.tsx index 62917eaf6..3116bdd5a 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/new/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/new/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; import { @@ -14,11 +14,14 @@ import { ErrorBanner } from "@/components/ui/error-banner"; import { BackIcon } from "@/components/ui/icons"; import { browserApiFetch } from "@/lib/browser-api-fetch"; import Link from "next/link"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; function NewAutomationContent() { const { isOpen } = useSidebarContext(); const router = useRouter(); const searchParams = useSearchParams(); + const { hasPermission, loading: authorizationLoading } = useCurrentUserAuthorization(); + const canCreate = hasPermission("automations.create"); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); @@ -29,6 +32,12 @@ function NewAutomationContent() { sentryWebhookUrl?: string; } | null>(null); + useEffect(() => { + if (!authorizationLoading && !canCreate) router.replace("/automations"); + }, [authorizationLoading, canCreate, router]); + + if (authorizationLoading || !canCreate) return null; + // A template id (from the gallery) pre-fills the form. Repository is never // pre-filled, so the repo-required-at-creation invariant is untouched. The // form coerces a template's suggested model against the user's enabled set. diff --git a/packages/web/src/app/(app)/(sidebar)/automations/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/page.test.tsx index 079e8141f..5024d0d60 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/page.test.tsx @@ -8,11 +8,14 @@ import AutomationsPage from "./page"; expect.extend(matchers); -const { mockReplace, mockUseAutomations, mockSearchParamsState } = vi.hoisted(() => ({ - mockReplace: vi.fn(), - mockUseAutomations: vi.fn(), - mockSearchParamsState: { value: new URLSearchParams() }, -})); +const { mockReplace, mockUseAutomations, mockSearchParamsState, mockPermissions } = vi.hoisted( + () => ({ + mockReplace: vi.fn(), + mockUseAutomations: vi.fn(), + mockSearchParamsState: { value: new URLSearchParams() }, + mockPermissions: new Set(), + }) +); vi.mock("next/navigation", () => ({ usePathname: () => "/automations", @@ -37,6 +40,12 @@ vi.mock("@/hooks/use-automations", () => ({ useAutomations: mockUseAutomations, })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => mockPermissions.has(permission), + }), +})); + vi.mock("@/components/automations/automations-list", () => ({ AutomationsList: ({ automations }: { automations: Array<{ name: string }> }) => (
{automations.map((automation) => automation.name).join(", ")}
@@ -58,6 +67,8 @@ describe("AutomationsPage", () => { vi.useFakeTimers(); mockReplace.mockReset(); mockSearchParamsState.value = new URLSearchParams(); + mockPermissions.clear(); + mockPermissions.add("automations.create"); mockUseAutomations.mockReturnValue(defaultHookResult); }); @@ -127,4 +138,12 @@ describe("AutomationsPage", () => { ); expect(mockUseAutomations).toHaveBeenLastCalledWith("weekly"); }); + + it("hides create and template entry points without automations.create", () => { + mockPermissions.clear(); + render(); + + expect(screen.queryByRole("link", { name: "Browse templates" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Create Automation" })).not.toBeInTheDocument(); + }); }); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/page.tsx index cd555b81e..0481a6f96 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/page.tsx @@ -11,6 +11,7 @@ import { ErrorBanner } from "@/components/ui/error-banner"; import { Input } from "@/components/ui/input"; import { PlusIcon, SearchIcon } from "@/components/ui/icons"; import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; const SEARCH_DEBOUNCE_MS = 300; @@ -32,6 +33,8 @@ function AutomationsContent() { const [nameSearch, setNameSearch] = useState(urlNameSearch); const { automations, loading, loadingMore, error, hasMore, loadMore, mutate } = useAutomations(committedNameSearch); + const { hasPermission } = useCurrentUserAuthorization(); + const canCreate = hasPermission("automations.create"); const [actionError, setActionError] = useState(null); @@ -92,17 +95,19 @@ function AutomationsContent() {

Automations

-
- - -
+ {canCreate && ( +
+ + +
+ )}
diff --git a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx new file mode 100644 index 000000000..c99c56086 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +/// + +import { cleanup, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AutomationTemplatesPage from "./page"; + +expect.extend(matchers); + +let canCreate = true; +const replace = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace }), +})); + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => permission === "automations.create" && canCreate, + loading: false, + }), +})); + +vi.mock("@/components/sidebar-layout", () => ({ + CollapsedSidebarControls: () => null, + useSidebarContext: () => ({ isOpen: true }), +})); + +vi.mock("@/components/automations/template-gallery", () => ({ + TemplateGallery: () =>
Template gallery
, +})); + +beforeEach(() => { + canCreate = true; + replace.mockReset(); +}); + +afterEach(cleanup); + +describe("AutomationTemplatesPage", () => { + it("renders templates with automations.create", () => { + render(); + expect(screen.getByRole("heading", { name: "Automation templates" })).toBeInTheDocument(); + }); + + it("redirects a direct template link without automations.create", () => { + canCreate = false; + render(); + + expect(replace).toHaveBeenCalledWith("/automations"); + expect(screen.queryByRole("heading", { name: "Automation templates" })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx index 752cbcb19..56d3936c9 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx @@ -1,12 +1,24 @@ "use client"; import Link from "next/link"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; import { TemplateGallery } from "@/components/automations/template-gallery"; import { BackIcon } from "@/components/ui/icons"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; export default function AutomationTemplatesPage() { const { isOpen } = useSidebarContext(); + const router = useRouter(); + const { hasPermission, loading } = useCurrentUserAuthorization(); + const canCreate = hasPermission("automations.create"); + + useEffect(() => { + if (!loading && !canCreate) router.replace("/automations"); + }, [canCreate, loading, router]); + + if (loading || !canCreate) return null; return (
diff --git a/packages/web/src/app/(app)/(sidebar)/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/page.test.tsx index 3692725b7..b75f742b9 100644 --- a/packages/web/src/app/(app)/(sidebar)/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/page.test.tsx @@ -81,6 +81,7 @@ const mocks = vi.hoisted(() => ({ ignoredProfileSkillIds: [], }, keyboardShortcuts: null as unknown as KeyboardShortcutPreferences, + canCreateSession: true, })); const repo = { @@ -97,6 +98,13 @@ vi.mock("@/lib/auth-session", () => ({ useAuthSession: () => ({ data: { user: { id: "user-1" } }, status: "authenticated" }), })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + permission === "sessions.create" && mocks.canCreateSession, + }), +})); + vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mocks.routerPush }), })); @@ -196,6 +204,7 @@ beforeEach(() => { mocks.providerAccountsValue = []; mocks.providerAccountsLoadingValue = false; mocks.keyboardShortcuts = DEFAULT_KEYBOARD_SHORTCUTS; + mocks.canCreateSession = true; mocks.routerPush.mockReset(); mocks.mutateMock.mockReset(); vi.stubGlobal( @@ -245,6 +254,17 @@ function activeOpenAiAccount(id: string): (typeof mocks.providerAccountsValue)[n } describe("Home", () => { + it("does not render session creation UI without session creation permission", () => { + mocks.canCreateSession = false; + + render(); + + expect(screen.getByText("You don't have permission to create sessions.")).toBeInTheDocument(); + expect(screen.queryByPlaceholderText("What do you want to build?")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /send/i })).not.toBeInTheDocument(); + expect(fetch).not.toHaveBeenCalled(); + }); + it("focuses the prompt when the page loads", () => { render(); diff --git a/packages/web/src/app/(app)/(sidebar)/page.tsx b/packages/web/src/app/(app)/(sidebar)/page.tsx index b8d503d4b..a81071a10 100644 --- a/packages/web/src/app/(app)/(sidebar)/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/page.tsx @@ -57,6 +57,7 @@ import type { import { ProviderAuthControls } from "@/components/provider-auth-controls"; import { useProviderAccounts } from "@/hooks/use-provider-accounts"; import { useWarmDraftSession, type WarmDraftSessionRequest } from "@/hooks/use-warm-draft-session"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; import { buildInteractiveProviderRoutingIdentity, parseStoredProviderSelections, @@ -89,6 +90,8 @@ function skillPreviewTarget( export default function Home() { const { data: session } = useAuthSession(); + const { hasPermission } = useCurrentUserAuthorization(); + const canCreateSession = hasPermission("sessions.create"); const router = useRouter(); const picker = useSessionTargetPicker(); const { sessionTarget, buildRequestFields, isLaunchable } = picker; @@ -184,6 +187,7 @@ export default function Home() { ); const warmRequest: WarmDraftSessionRequest | null = + canCreateSession && session && providerSelectionsHydrated && !providerAccounts.loading && @@ -267,6 +271,7 @@ export default function Home() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if ( + !canCreateSession || submitInFlightRef.current || sessionAttachments.isUploading || !providerSelectionsHydrated || @@ -343,6 +348,7 @@ export default function Home() { return ( void; @@ -477,17 +485,21 @@ function HomeContent({ {/* Welcome text */}

Welcome to {APP_NAME}

- {isAuthenticated ? ( + {isAuthenticated && canCreateSession ? (

Ask a question or describe what you want to build

+ ) : isAuthenticated ? ( +

+ You don't have permission to create sessions. +

) : (

Sign in to start a new session

)}
{/* Input box - only show when authenticated */} - {isAuthenticated && ( + {isAuthenticated && canCreateSession && ( {error && {error}} diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx index 9fa0295de..9edf40da8 100644 --- a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx @@ -63,6 +63,7 @@ import { usePromptInput } from "@/hooks/use-prompt-input"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useSessionSnapshot } from "./session-snapshot-provider"; import { useSessionRename } from "@/hooks/use-session-rename"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; type SessionState = ReturnType["sessionState"]; @@ -71,6 +72,10 @@ const DEFAULT_SESSION_STATUS = "created" as const; export default function SessionPage() { const { shortcuts } = useKeyboardShortcuts(); + const { hasPermission } = useCurrentUserAuthorization(); + const canCollaborate = hasPermission("sessions.collaborate"); + const canManageLifecycle = hasPermission("sessions.lifecycle"); + const canAccessSandbox = hasPermission("sessions.sandbox_access"); const initialSnapshot = useSessionSnapshot(); const sessionId = initialSnapshot.session.id; const { @@ -95,7 +100,10 @@ export default function SessionPage() { sendTyping, reconnect, loadOlderEvents, - } = useSessionSocket(sessionId, initialSnapshot); + } = useSessionSocket(sessionId, initialSnapshot, { + collaborate: canCollaborate, + sandboxAccess: canAccessSandbox, + }); const { profiles, participants: profiledParticipants } = useSessionParticipantProfiles( sessionId, participants, @@ -143,7 +151,7 @@ export default function SessionPage() { reasoningEffort, loadingEnabledModels, sessionState?.status ?? DEFAULT_SESSION_STATUS, - ready, + ready && canCollaborate, shortcuts["send-prompt"] ); const [cancellingPromptIds, setCancellingPromptIds] = useState>(new Set()); @@ -217,7 +225,7 @@ export default function SessionPage() { }, [applyTerminalOpen]); const ttydUrl = sessionState?.ttydUrl; const ttydToken = sessionState?.ttydToken; - const showTerminal = !!(ttydUrl && ttydToken && terminalOpen && !isBelowLg); + const showTerminal = !!(canAccessSandbox && ttydUrl && ttydToken && terminalOpen && !isBelowLg); const toggleDetails = useCallback(() => { setIsDetailsOpen((prev) => !prev); @@ -355,44 +363,48 @@ export default function SessionPage() { promptQueue={promptQueue} cancellingPromptIds={cancellingPromptIds} onRemove={handleRemoveQueuedPrompt} + canRemove={canCollaborate} /> - + {canCollaborate && ( + + )}
); @@ -419,13 +431,17 @@ export default function SessionPage() { primaryRepo, onArchive: handleArchive, onUnarchive: handleUnarchive, + canManageLifecycle, }} optimisticTitle={optimisticTitle} renameSession={renameSession} + canRename={canManageLifecycle} + showConnectionStatus={canCollaborate} + canAccessSandbox={canAccessSandbox} /> {/* Connection error banner */} - {(authError || connectionError) && ( + {canCollaborate && (authError || connectionError) && (

{authError || connectionError}

+ {canManageLifecycle && ( + + )} {mediaCount > 0 && (
@@ -140,11 +143,13 @@ export function ActionBar({
- + {canManageLifecycle && ( + + )} ); } diff --git a/packages/web/src/components/automations/automations-list.test.tsx b/packages/web/src/components/automations/automations-list.test.tsx index 5c20c2bbb..3714bfca7 100644 --- a/packages/web/src/components/automations/automations-list.test.tsx +++ b/packages/web/src/components/automations/automations-list.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /// -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; import type { ComponentProps } from "react"; @@ -26,6 +26,22 @@ vi.mock("@/hooks/use-environments", () => ({ })); const noop = () => {}; +const CURRENT_USER_ID = "11111111111111111111111111111111"; +let permissions = ["automations.create", "automations.manage.own", "automations.trigger.own"]; + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + authorization: { + userId: CURRENT_USER_ID, + permissions, + }, + hasPermission: (permission: string) => permissions.includes(permission), + }), +})); + +beforeEach(() => { + permissions = ["automations.create", "automations.manage.own", "automations.trigger.own"]; +}); function makeAutomation(overrides: Partial = {}): AutomationListItem { return { @@ -41,7 +57,7 @@ function makeAutomation(overrides: Partial = {}): Automation nextRunAt: null, consecutiveFailures: 0, createdBy: "user-1", - userId: "11111111111111111111111111111111", + userId: CURRENT_USER_ID, createdAt: Date.now(), updatedAt: Date.now(), deletedAt: null, @@ -118,6 +134,50 @@ describe("AutomationsList schedule metadata", () => { }); describe("AutomationsList actions", () => { + const renderListWithActions = (automation: AutomationListItem) => + render( + + ); + + it("uses canonical ownership for own-scoped controls", () => { + render( + + ); + + expect(screen.queryByRole("button", { name: "Pause" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Trigger" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /actions for/i })).not.toBeInTheDocument(); + }); + + it("gates manage and trigger controls independently", () => { + permissions = ["automations.manage.any"]; + renderListWithActions(makeAutomation({ userId: null })); + + expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Trigger" })).not.toBeInTheDocument(); + }); + it("offers row actions from the compact menu", async () => { const onTrigger = vi.fn(); render( @@ -237,6 +297,23 @@ describe("AutomationsList empty state", () => { ); }); + it("hides creation entry points without automations.create", () => { + permissions = []; + render( + + ); + + expect(screen.queryByRole("link", { name: /template/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /create automation/i })).not.toBeInTheDocument(); + }); + it("describes an empty name search without showing creation prompts", () => { render( (null); const { environments } = useEnvironments(); - const automationToDelete = automations.find((automation) => automation.id === confirmDeleteId); + const { authorization, hasPermission } = useCurrentUserAuthorization(); + const canCreate = hasPermission("automations.create"); + const automationToDelete = automations.find( + (automation) => + automation.id === confirmDeleteId && + canAccessAutomation("automations.manage", authorization, automation) + ); if (automations.length === 0) { if (emptyState.kind === "no-search-results") { @@ -112,14 +120,16 @@ export function AutomationsList({

Start from a template, or create one to run tasks on a schedule or in response to events.

-
- - -
+ {canCreate && ( +
+ + +
+ )}
); } @@ -127,105 +137,122 @@ export function AutomationsList({ return ( <>
- {automations.map((automation) => ( -
- {/* Header: Name + badge | Actions */} -
-
- - {automation.name} - - - -
-
- {automation.enabled ? ( - - ) : ( - + {automations.map((automation) => { + const canManage = canAccessAutomation("automations.manage", authorization, automation); + const canTrigger = canAccessAutomation("automations.trigger", authorization, automation); + return ( +
+ {/* Header: Name + badge | Actions */} +
+
+ + {automation.name} + + + +
+
+ {canManage && + (automation.enabled ? ( + + ) : ( + + ))} + {canTrigger && ( + + )} + {canManage && ( + + )} +
+ {(canManage || canTrigger) && ( + + + + + + {canManage && ( + + automation.enabled ? onPause(automation.id) : onResume(automation.id) + } + > + {automation.enabled ? "Pause" : "Resume"} + + )} + {canTrigger && ( + onTrigger(automation.id)}> + + )} + {canManage && ( + setConfirmDeleteId(automation.id)} + > + Delete + + )} + + )} - -
- - - - - - - automation.enabled ? onPause(automation.id) : onResume(automation.id) - } - > - {automation.enabled ? "Pause" : "Resume"} - - onTrigger(automation.id)}> - - setConfirmDeleteId(automation.id)} - > - Delete - - - -
- {/* Metadata: icon-paired items */} -
- - {automation.environmentIds.length > 0 && automation.repositories.length === 0 ? ( - - -
-
- ))} + ); + })}
{message}

- + {canRetry && ( + + )}
{retryError && (

@@ -106,23 +107,27 @@ export function MobileSessionActions({ Copy link - - - - {controls.isArchived ? "Unarchive" : "Archive"} - + {canManageLifecycle && } + {canManageLifecycle && ( + + + {controls.isArchived ? "Unarchive" : "Archive"} + + )}

- + {canManageLifecycle && ( + + )} ); } diff --git a/packages/web/src/components/queued-prompt-stack.test.tsx b/packages/web/src/components/queued-prompt-stack.test.tsx index 95db55c53..67e6ccfab 100644 --- a/packages/web/src/components/queued-prompt-stack.test.tsx +++ b/packages/web/src/components/queued-prompt-stack.test.tsx @@ -11,6 +11,19 @@ expect.extend(matchers); afterEach(cleanup); describe("QueuedPromptStack", () => { + it("shows queued prompts without removal controls in read-only mode", () => { + render( + + ); + + expect(screen.getByText("Review this")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Remove queued prompt/ })).not.toBeInTheDocument(); + }); it("renders only pending prompts in FIFO order", () => { render( ; onRemove: (messageId: string) => void; + canRemove?: boolean; }) { const pendingPrompts = promptQueue.filter((item) => item.status === "pending"); if (pendingPrompts.length === 0) return null; @@ -28,16 +30,18 @@ export function QueuedPromptStack({

{prompt.content}

- + {canRemove && ( + + )} ))} diff --git a/packages/web/src/components/session-actions.ts b/packages/web/src/components/session-actions.ts index 774a757c5..ee7ae2b6a 100644 --- a/packages/web/src/components/session-actions.ts +++ b/packages/web/src/components/session-actions.ts @@ -18,6 +18,7 @@ export interface SessionActionProps { primaryRepo?: { repoOwner: string; repoName: string } | null; onArchive?: () => void | Promise; onUnarchive?: () => void | Promise; + canManageLifecycle?: boolean; } /** One PR a session-level action can open, ready to render as a link. */ diff --git a/packages/web/src/components/session-changes-panel.tsx b/packages/web/src/components/session-changes-panel.tsx index 28e383972..10b3c31dd 100644 --- a/packages/web/src/components/session-changes-panel.tsx +++ b/packages/web/src/components/session-changes-panel.tsx @@ -225,6 +225,7 @@ export function SessionChangesPanel({ onClose, onSelect, mobile = false, + canRetry = true, }: { sessionId: string; state: SessionDiffState; @@ -232,6 +233,7 @@ export function SessionChangesPanel({ onClose: () => void; onSelect: (selection: DiffSelection) => void; mobile?: boolean; + canRetry?: boolean; }) { const panelRef = useRef(null); const fileListId = useId(); @@ -308,7 +310,12 @@ export function SessionChangesPanel({ /> {state.lastError && ( - + )}
diff --git a/packages/web/src/components/session-details-overlay.tsx b/packages/web/src/components/session-details-overlay.tsx index 9487ff9fe..3fd4db949 100644 --- a/packages/web/src/components/session-details-overlay.tsx +++ b/packages/web/src/components/session-details-overlay.tsx @@ -47,6 +47,9 @@ export function SessionDetailsOverlay({ diffLoading, selectedDiff, onOpenDiff, + canAccessSandbox, + canManageLifecycle, + canRetryDiff, }: SessionDetailsOverlayProps) { const [sheetDragY, setSheetDragY] = useState(0); const sheetDragYRef = useRef(0); @@ -175,6 +178,9 @@ export function SessionDetailsOverlay({ diffLoading={diffLoading} selectedDiff={selectedDiff} onOpenDiff={onOpenDiff} + canAccessSandbox={canAccessSandbox} + canManageLifecycle={canManageLifecycle} + canRetryDiff={canRetryDiff} /> ); diff --git a/packages/web/src/components/session-header.test.tsx b/packages/web/src/components/session-header.test.tsx index e215c7923..ec1f40581 100644 --- a/packages/web/src/components/session-header.test.tsx +++ b/packages/web/src/components/session-header.test.tsx @@ -43,6 +43,36 @@ function createSessionState(overrides: Partial = {}): SessionState } describe("SessionHeader", () => { + it("disables lifecycle controls and connection UI for a read-only session", async () => { + render( + ()} + actionsButtonRef={createRef()} + onToggleDetails={vi.fn()} + onToggleDesktopDetails={vi.fn()} + onOpenMobileDetails={vi.fn()} + actions={{ ...actions, canManageLifecycle: false }} + renameSession={vi.fn()} + canRename={false} + showConnectionStatus={false} + /> + ); + + expect(screen.getByRole("button", { name: "Session 1" })).toBeDisabled(); + expect(screen.queryByRole("status", { name: /Connection status/ })).not.toBeInTheDocument(); + + const trigger = screen.getByRole("button", { name: "Session actions" }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false }); + expect(screen.queryByRole("menuitem", { name: "Archive" })).not.toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Copy link" })).toBeInTheDocument(); + }); it("lets desktop users hide and show the session details sidebar", () => { const onToggleDesktopDetails = vi.fn(); const { rerender } = render( diff --git a/packages/web/src/components/session-header.tsx b/packages/web/src/components/session-header.tsx index 5e9784a00..20e7ca698 100644 --- a/packages/web/src/components/session-header.tsx +++ b/packages/web/src/components/session-header.tsx @@ -100,6 +100,9 @@ export type SessionHeaderProps = { actions: SessionActionProps; optimisticTitle?: string; renameSession: (title: string) => Promise; + canRename?: boolean; + showConnectionStatus?: boolean; + canAccessSandbox?: boolean; }; export function SessionHeader({ @@ -119,6 +122,9 @@ export function SessionHeader({ actions, optimisticTitle, renameSession, + canRename = true, + showConnectionStatus = true, + canAccessSandbox = true, }: SessionHeaderProps) { const { isOpen } = useSidebarContext(); const hasFallbackSessionInfo = @@ -138,6 +144,7 @@ export function SessionHeader({ optimisticTitle ?? sessionState?.title ?? fallbackSessionInfo.title ?? repoLabel; const handleStartRename = () => { + if (!canRename) return; setTitle(resolvedTitle); setIsRenaming(true); }; @@ -196,9 +203,10 @@ export function SessionHeader({

@@ -226,10 +234,12 @@ export function SessionHeader({ onOpenMedia={onOpenMobileDetails} />
- + {showConnectionStatus && ( + + )}
diff --git a/packages/web/src/components/session-list-item.test.tsx b/packages/web/src/components/session-list-item.test.tsx new file mode 100644 index 000000000..17de0d41b --- /dev/null +++ b/packages/web/src/components/session-list-item.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +/// + +import { fireEvent, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { beforeEach, expect, it, vi } from "vitest"; +import type { SessionItem } from "@/hooks/use-sidebar-sessions"; +import { SessionListItem } from "./session-list-item"; + +expect.extend(matchers); + +const mocks = vi.hoisted(() => ({ + allowedPermissions: new Set(), +})); + +vi.mock("next/link", () => ({ + default: ({ children, ...props }: React.ComponentProps<"a">) => {children}, +})); + +vi.mock("@/hooks/use-session-rename", () => ({ + useSessionRename: () => ({ optimisticTitle: null, renameSession: vi.fn() }), +})); + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => mocks.allowedPermissions.has(permission), + }), +})); + +beforeEach(() => { + mocks.allowedPermissions = new Set(); +}); + +function session(unread = false): SessionItem { + return { + id: "session-1", + title: "Session one", + repoOwner: null, + repoName: null, + baseBranch: null, + status: "active", + parentSessionId: null, + spawnSource: "user", + environmentId: null, + createdAt: 1, + updatedAt: 2, + readState: unread + ? { latestMessageId: "message-1", unread: true } + : { latestMessageId: null, unread: false }, + }; +} + +function renderItem(unread = false) { + render( + + ); +} + +it("fails closed when sessions.lifecycle is denied", () => { + renderItem(); + + expect(screen.queryByRole("button", { name: "Session actions" })).not.toBeInTheDocument(); +}); + +it("shows rename and archive actions when sessions.lifecycle is allowed", async () => { + mocks.allowedPermissions.add("sessions.lifecycle"); + renderItem(); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Session actions" }), { + button: 0, + ctrlKey: false, + }); + + expect(await screen.findByRole("menuitem", { name: "Rename" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Archive" })).toBeInTheDocument(); +}); + +it("keeps mark-as-read available without sessions.lifecycle", async () => { + renderItem(true); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Session actions" }), { + button: 0, + ctrlKey: false, + }); + + expect(await screen.findByRole("menuitem", { name: "Mark as read" })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "Rename" })).not.toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "Archive" })).not.toBeInTheDocument(); +}); diff --git a/packages/web/src/components/session-list-item.tsx b/packages/web/src/components/session-list-item.tsx index f4bd2d503..6ca964b70 100644 --- a/packages/web/src/components/session-list-item.tsx +++ b/packages/web/src/components/session-list-item.tsx @@ -10,6 +10,7 @@ import { formatRelativeTime } from "@/lib/time"; import { MoreIcon, ArchiveIcon, BranchIcon, BoxIcon } from "@/components/ui/icons"; import { formatSessionRepositoriesLabel } from "@/lib/repo-label"; import { useSessionRename } from "@/hooks/use-session-rename"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; import { DropdownMenu, DropdownMenuContent, @@ -22,6 +23,9 @@ import { buildSessionHref } from "@/lib/session-list"; export const MOBILE_LONG_PRESS_MS = 450; const MOBILE_LONG_PRESS_MOVE_THRESHOLD_PX = 10; +/** + * Displays a session and derives lifecycle controls from the current user's workspace permissions. + */ export function SessionListItem({ session, environmentName, @@ -39,6 +43,8 @@ export function SessionListItem({ onSessionSelect?: () => void; onMarkLatestMessageRead: (sessionId: string) => Promise; }) { + const { hasPermission } = useCurrentUserAuthorization(); + const canManageLifecycle = hasPermission("sessions.lifecycle"); const timestamp = session.updatedAt || session.createdAt; const relativeTime = formatRelativeTime(timestamp); const repoInfo = formatSessionRepositoriesLabel( @@ -73,6 +79,7 @@ export function SessionListItem({ }, [displayTitle, isRenaming]); const handleStartRename = () => { + if (!canManageLifecycle) return; isStartingRenameRef.current = true; setIsActionsOpen(false); setTitle(displayTitle); @@ -96,6 +103,7 @@ export function SessionListItem({ }; const handleStartArchive = () => { + if (!canManageLifecycle) return; setIsActionsOpen(false); setShowArchiveDialog(true); }; @@ -161,11 +169,12 @@ export function SessionListItem({ touchStartRef.current = { x: touch.clientX, y: touch.clientY }; clearLongPressTimer(); longPressTimerRef.current = window.setTimeout(() => { + if (!canManageLifecycle && !session.readState.unread) return; longPressTriggeredRef.current = true; setIsActionsOpen(true); }, MOBILE_LONG_PRESS_MS); }, - [clearLongPressTimer, isMobile] + [canManageLifecycle, clearLongPressTimer, isMobile, session.readState.unread] ); const handleTouchMove = useCallback( @@ -303,57 +312,65 @@ export function SessionListItem({ )} -
- - - - - { - if (isStartingRenameRef.current) { - event.preventDefault(); - isStartingRenameRef.current = false; - } - }} - > - Rename - {session.readState.unread && ( - + + +
+ + + + { + if (isStartingRenameRef.current) { + event.preventDefault(); + isStartingRenameRef.current = false; + } + }} + > + {canManageLifecycle && ( + Rename + )} + {session.readState.unread && ( + + Mark as read + + )} + {canManageLifecycle && ( + + + Archive + + )} + + +

+ )}
- + {canManageLifecycle && ( + + )} ); } diff --git a/packages/web/src/components/session-prompt-composer.tsx b/packages/web/src/components/session-prompt-composer.tsx index 675617be1..50bf53179 100644 --- a/packages/web/src/components/session-prompt-composer.tsx +++ b/packages/web/src/components/session-prompt-composer.tsx @@ -24,6 +24,7 @@ type SessionPromptComposerProps = { primaryRepo?: { repoOwner: string; repoName: string } | null; onArchive: () => void | Promise; onUnarchive: () => void | Promise; + canManageLifecycle?: boolean; }; prompt: { value: string; @@ -105,6 +106,7 @@ export function SessionPromptComposer({ primaryRepo={session.primaryRepo} onArchive={session.onArchive} onUnarchive={session.onUnarchive} + canManageLifecycle={session.canManageLifecycle} />
diff --git a/packages/web/src/components/session-right-sidebar.test.tsx b/packages/web/src/components/session-right-sidebar.test.tsx index 2660b5e1b..5d9244cf3 100644 --- a/packages/web/src/components/session-right-sidebar.test.tsx +++ b/packages/web/src/components/session-right-sidebar.test.tsx @@ -3,12 +3,51 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SessionRightSidebar } from "./session-right-sidebar"; +import type { SessionState } from "@open-inspect/shared/types/server-messages"; vi.mock("swr", () => ({ default: () => ({ data: undefined }) })); afterEach(cleanup); describe("SessionRightSidebar", () => { + it("hides sandbox access controls when the capability is denied", () => { + const sessionState: SessionState = { + id: "session-1", + title: "Viewer session", + repoOwner: "acme", + repoName: "web", + baseBranch: "main", + branchName: "viewer", + status: "active", + sandboxStatus: "ready", + messageCount: 0, + createdAt: 1, + codeServerUrl: "https://code.example", + vncUrl: "https://vnc.example", + ttydUrl: "https://terminal.example", + ttydToken: "secret", + tunnelUrls: { app: "https://app.example" }, + }; + + render( + + ); + + expect(screen.queryByText("Open Editor")).not.toBeInTheDocument(); + expect(screen.queryByText("Open Desktop")).not.toBeInTheDocument(); + expect(screen.queryByText("Terminal")).not.toBeInTheDocument(); + expect(screen.queryByText("Port app")).not.toBeInTheDocument(); + expect(screen.getByText("main")).toBeInTheDocument(); + }); it("keeps its ARIA target mounted when closed", () => { render( void; + canAccessSandbox?: boolean; + canManageLifecycle?: boolean; + canRetryDiff?: boolean; } export type SessionRightSidebarContentProps = SessionRightSidebarProps; @@ -59,6 +62,9 @@ export function SessionRightSidebarContent({ diffLoading, selectedDiff, onOpenDiff, + canAccessSandbox = true, + canManageLifecycle = true, + canRetryDiff = true, }: SessionRightSidebarContentProps) { const tasks = useMemo(() => extractLatestTasks(events), [events]); const warnings = useMemo( @@ -124,11 +130,12 @@ export function SessionRightSidebarContent({ warnings={warnings} parentSessionId={sessionState.parentSessionId} totalCost={sessionState.totalCost} + canManageLifecycle={canManageLifecycle} />
{/* Code Server */} - {sessionState.codeServerUrl && ( + {canAccessSandbox && sessionState.codeServerUrl && (
@@ -182,14 +189,16 @@ export function SessionRightSidebarContent({ )} {/* Tunnel URLs */} - {sessionState.tunnelUrls && Object.keys(sessionState.tunnelUrls).length > 0 && ( -
- -
- )} + {canAccessSandbox && + sessionState.tunnelUrls && + Object.keys(sessionState.tunnelUrls).length > 0 && ( +
+ +
+ )} {/* Tasks */} {tasks.length > 0 && ( @@ -243,6 +252,7 @@ export function SessionRightSidebarContent({ sessionId={sessionId} message={diffView.message ?? ""} variant="inline" + canRetry={canRetryDiff} /> )}
@@ -287,6 +297,9 @@ export function SessionRightSidebar({ diffLoading, selectedDiff, onOpenDiff, + canAccessSandbox, + canManageLifecycle, + canRetryDiff, }: SessionRightSidebarProps) { return ( ); diff --git a/packages/web/src/components/session-sidebar.tsx b/packages/web/src/components/session-sidebar.tsx index 1394a1adc..dce762aa8 100644 --- a/packages/web/src/components/session-sidebar.tsx +++ b/packages/web/src/components/session-sidebar.tsx @@ -70,6 +70,7 @@ export function SessionSidebar({ }: SessionSidebarProps) { const { labels } = useKeyboardShortcuts(); const { data: authSession } = useAuthSession(); + const { hasPermission } = useCurrentUserAuthorization(); const pathname = usePathname(); const router = useRouter(); const isMobile = useIsMobile(); diff --git a/packages/web/src/components/sidebar-layout.test.tsx b/packages/web/src/components/sidebar-layout.test.tsx index 587fb1e3a..75eb99ccd 100644 --- a/packages/web/src/components/sidebar-layout.test.tsx +++ b/packages/web/src/components/sidebar-layout.test.tsx @@ -11,6 +11,7 @@ expect.extend(matchers); const mocks = vi.hoisted(() => ({ isMobile: false, + canCreateSession: true, sidebar: { isOpen: true, toggle: vi.fn(), @@ -32,10 +33,18 @@ vi.mock("@/hooks/use-sidebar", () => ({ useSidebar: () => mocks.sidebar, })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + permission === "sessions.create" && mocks.canCreateSession, + }), +})); + afterEach(() => { cleanup(); vi.clearAllMocks(); mocks.isMobile = false; + mocks.canCreateSession = true; mocks.sidebar.isOpen = true; }); @@ -63,6 +72,19 @@ describe("CollapsedSidebarControls", () => { fireEvent.click(buttons![2]); expect(push).toHaveBeenCalledWith("/"); }); + + it("hides the new session action without session creation permission", () => { + mocks.canCreateSession = false; + vi.mocked(useRouter).mockReturnValue({ push: vi.fn() } as never); + + render( + + + + ); + + expect(screen.queryByRole("button", { name: /New session/ })).not.toBeInTheDocument(); + }); }); describe("mobile sidebar drag", () => { diff --git a/packages/web/src/components/sidebar-layout.tsx b/packages/web/src/components/sidebar-layout.tsx index 5c6ecfcaf..e7b385fc9 100644 --- a/packages/web/src/components/sidebar-layout.tsx +++ b/packages/web/src/components/sidebar-layout.tsx @@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"; import { SidebarIcon } from "@/components/ui/icons"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useMobileSidebarPull } from "@/hooks/use-mobile-sidebar-pull"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; interface SidebarContextValue { isOpen: boolean; @@ -60,6 +61,7 @@ export function SidebarToggleButton({ label = "Open sidebar" }: { label?: string export function CollapsedSidebarControls() { const actions = useContext(AppShellActionsContext); + const { hasPermission } = useCurrentUserAuthorization(); if (!actions) { throw new Error("CollapsedSidebarControls must be used within a SidebarLayout"); } @@ -68,13 +70,15 @@ export function CollapsedSidebarControls() {
- + {hasPermission("sessions.create") && }
); } export function SidebarLayout({ children }: SidebarLayoutProps) { const router = useRouter(); + const { hasPermission } = useCurrentUserAuthorization(); + const canCreateSession = hasPermission("sessions.create"); const sidebar = useSidebar(); const isMobile = useIsMobile(); const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false); @@ -95,12 +99,13 @@ export function SidebarLayout({ children }: SidebarLayoutProps) { ); const handleNewSession = useCallback(() => { + if (!canCreateSession) return; setIsCommandMenuOpen(false); if (isMobile) { sidebar.close(); } router.push("/"); - }, [isMobile, router, sidebar]); + }, [canCreateSession, isMobile, router, sidebar]); const handleNavigate = useCallback( (href: string) => { diff --git a/packages/web/src/components/sidebar/metadata-section.tsx b/packages/web/src/components/sidebar/metadata-section.tsx index 9b16187a0..4d56aa061 100644 --- a/packages/web/src/components/sidebar/metadata-section.tsx +++ b/packages/web/src/components/sidebar/metadata-section.tsx @@ -52,6 +52,7 @@ interface MetadataSectionProps { warnings?: WarningEvent[]; parentSessionId?: string | null; totalCost?: number; + canManageLifecycle?: boolean; } /** @@ -108,12 +109,13 @@ export function MetadataSection({ warnings = [], parentSessionId, totalCost, + canManageLifecycle = true, }: MetadataSectionProps) { const [copied, setCopied] = useState(false); const isMultiRepo = (repositories?.length ?? 0) > 1; const hasPrArtifact = artifacts.some((a) => a.type === "pr"); - const showSyncButton = Boolean(sessionId) && hasPrArtifact; + const showSyncButton = canManageLifecycle && Boolean(sessionId) && hasPrArtifact; // Sessions can hold several PRs (one open PR per head branch); list them // all, oldest first — creation order matches PR-number order. diff --git a/packages/web/src/hooks/use-global-shortcuts.test.tsx b/packages/web/src/hooks/use-global-shortcuts.test.tsx index 68b8b24ef..3b0507bcc 100644 --- a/packages/web/src/hooks/use-global-shortcuts.test.tsx +++ b/packages/web/src/hooks/use-global-shortcuts.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_KEYBOARD_SHORTCUTS } from "@open-inspect/shared/types/keyboard-shortcuts"; import { useGlobalShortcuts } from "./use-global-shortcuts"; +const mocks = vi.hoisted(() => ({ canCreateSession: true })); + const shortcuts = { ...DEFAULT_KEYBOARD_SHORTCUTS, "open-command-menu": { code: "KeyP", primary: true, alt: false, shift: false }, @@ -16,8 +18,18 @@ vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ useKeyboardShortcuts: () => ({ shortcuts }), })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + permission === "sessions.create" && mocks.canCreateSession, + }), +})); + describe("useGlobalShortcuts", () => { - afterEach(() => vi.restoreAllMocks()); + afterEach(() => { + mocks.canCreateSession = true; + vi.restoreAllMocks(); + }); it("dispatches the configured action and removes its listener", () => { const onOpenCommandMenu = vi.fn(); @@ -46,4 +58,27 @@ describe("useGlobalShortcuts", () => { window.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyP", ctrlKey: true })); expect(onOpenCommandMenu).toHaveBeenCalledOnce(); }); + + it("ignores the new session shortcut without session creation permission", () => { + mocks.canCreateSession = false; + const onNewSession = vi.fn(); + renderHook(() => + useGlobalShortcuts({ + onOpenCommandMenu: vi.fn(), + onNewSession, + onToggleSidebar: vi.fn(), + }) + ); + + const event = new KeyboardEvent("keydown", { + code: "KeyN", + ctrlKey: true, + shiftKey: true, + cancelable: true, + }); + window.dispatchEvent(event); + + expect(onNewSession).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); }); diff --git a/packages/web/src/hooks/use-global-shortcuts.ts b/packages/web/src/hooks/use-global-shortcuts.ts index 33446518c..2f5d41355 100644 --- a/packages/web/src/hooks/use-global-shortcuts.ts +++ b/packages/web/src/hooks/use-global-shortcuts.ts @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { matchGlobalShortcut, shouldIgnoreGlobalShortcutForAction } from "@/lib/keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; interface UseGlobalShortcutsOptions { enabled?: boolean; @@ -18,6 +19,8 @@ export function useGlobalShortcuts({ onToggleSidebar, }: UseGlobalShortcutsOptions) { const { shortcuts } = useKeyboardShortcuts(); + const { hasPermission } = useCurrentUserAuthorization(); + const canCreateSession = hasPermission("sessions.create"); useEffect(() => { if (!enabled) return; @@ -25,6 +28,7 @@ export function useGlobalShortcuts({ const action = matchGlobalShortcut(event, shortcuts); if (!action) return; if (shouldIgnoreGlobalShortcutForAction(event, action)) return; + if (action === "new-session" && !canCreateSession) return; event.preventDefault(); @@ -35,5 +39,5 @@ export function useGlobalShortcuts({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [enabled, onNewSession, onOpenCommandMenu, onToggleSidebar, shortcuts]); + }, [canCreateSession, enabled, onNewSession, onOpenCommandMenu, onToggleSidebar, shortcuts]); } diff --git a/packages/web/src/hooks/use-sandbox-access.ts b/packages/web/src/hooks/use-sandbox-access.ts index d28b3a1d5..28c943041 100644 --- a/packages/web/src/hooks/use-sandbox-access.ts +++ b/packages/web/src/hooks/use-sandbox-access.ts @@ -22,10 +22,11 @@ const sandboxAccessSchema = z type SandboxAccess = z.infer; -export function useSandboxAccess(sessionId: string, isSandboxReady: boolean) { - const key: BrowserApiPath | null = isSandboxReady - ? `/api/sessions/${encodeURIComponent(sessionId)}/sandbox-access` - : null; +export function useSandboxAccess(sessionId: string, isSandboxReady: boolean, enabled = true) { + const key: BrowserApiPath | null = + enabled && isSandboxReady + ? `/api/sessions/${encodeURIComponent(sessionId)}/sandbox-access` + : null; const { data, mutate } = useSWR(key, async (url: BrowserApiPath) => { const response = await browserApiFetch(url, { cache: "no-store" }); if (response.status === 204 || response.status === 404) return null; diff --git a/packages/web/src/hooks/use-session-socket.test.tsx b/packages/web/src/hooks/use-session-socket.test.tsx index 477625509..d08cf333f 100644 --- a/packages/web/src/hooks/use-session-socket.test.tsx +++ b/packages/web/src/hooks/use-session-socket.test.tsx @@ -139,6 +139,28 @@ describe("useSessionSocket", () => { vi.restoreAllMocks(); }); + it("keeps the HTTP snapshot available without collaboration or sandbox requests", async () => { + const fetchMock = vi.mocked(fetch); + const snapshot = createSnapshot(); + snapshot.session.title = "Read-only snapshot"; + + const { result } = renderHook(() => + useSessionSocket("session-1", snapshot, { + collaborate: false, + sandboxAccess: false, + }) + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.sessionState?.title).toBe("Read-only snapshot"); + expect(result.current.connected).toBe(false); + expect(FakeWebSocket.instances).toHaveLength(0); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("keeps sendPrompt pending until the server acknowledges the queued prompt", async () => { const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); diff --git a/packages/web/src/hooks/use-session-socket.ts b/packages/web/src/hooks/use-session-socket.ts index 72ef39868..ec9d622c6 100644 --- a/packages/web/src/hooks/use-session-socket.ts +++ b/packages/web/src/hooks/use-session-socket.ts @@ -99,7 +99,11 @@ interface PendingCorrelatedRequest { */ export function useSessionSocket( sessionId: string, - initialSnapshot: SessionSnapshot + initialSnapshot: SessionSnapshot, + capabilities: { collaborate: boolean; sandboxAccess: boolean } = { + collaborate: true, + sandboxAccess: true, + } ): UseSessionSocketReturn { const [state, dispatch] = useReducer( sessionSocketReducer, @@ -117,7 +121,11 @@ export function useSessionSocket( sandboxAccess, clear: clearSandboxAccess, refresh: refreshSandboxAccess, - } = useSandboxAccess(sessionId, state.sessionState?.sandboxStatus === "ready"); + } = useSandboxAccess( + sessionId, + state.sessionState?.sandboxStatus === "ready", + capabilities.sandboxAccess + ); const settleSubscriptionWaiters = useCallback((subscribed: boolean) => { for (const resolve of subscriptionWaitersRef.current) { @@ -228,10 +236,14 @@ export function useSessionSocket( dispatch({ type: "socket_closed" }); }, [settleAllCorrelatedRequests, settleSubscriptionWaiters]); - const transport = useSessionTransport(sessionId, { - onMessage: handleMessage, - onClose: handleClose, - }); + const transport = useSessionTransport( + sessionId, + { + onMessage: handleMessage, + onClose: handleClose, + }, + capabilities.collaborate + ); const { isOpen, send, reconnect, markHealthy } = transport; useEffect(() => { diff --git a/packages/web/src/hooks/use-session-transport.test.tsx b/packages/web/src/hooks/use-session-transport.test.tsx index 6022c8ca7..dcf40ee0f 100644 --- a/packages/web/src/hooks/use-session-transport.test.tsx +++ b/packages/web/src/hooks/use-session-transport.test.tsx @@ -110,6 +110,25 @@ describe("useSessionTransport", () => { expect(result.current.isOpen()).toBe(true); }); + it("does not fetch a token or open a socket when transport is disabled", async () => { + const { result } = renderHook(() => + useSessionTransport("session-1", { onMessage, onClose }, false) + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(FakeWebSocket.instances).toHaveLength(0); + expect(result.current.connected).toBe(false); + expect(result.current.connecting).toBe(false); + + act(() => result.current.reconnect()); + expect(fetchMock).not.toHaveBeenCalled(); + expect(FakeWebSocket.instances).toHaveLength(0); + }); + it("forwards schema-valid messages to onMessage", async () => { const { socket } = await openSocket(); diff --git a/packages/web/src/hooks/use-session-transport.ts b/packages/web/src/hooks/use-session-transport.ts index 31b83e81d..3e50eff3e 100644 --- a/packages/web/src/hooks/use-session-transport.ts +++ b/packages/web/src/hooks/use-session-transport.ts @@ -23,6 +23,7 @@ const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8787"; const WS_CLOSE_AUTH_REQUIRED = 4001; const WS_CLOSE_SESSION_EXPIRED = 4002; const WS_CLOSE_INVALID_MESSAGE = 4004; +const WS_CLOSE_AUTHORIZATION_REVOKED = 4010; const MAX_RECONNECT_ATTEMPTS = 5; const RECONNECT_BASE_DELAY_MS = 1000; @@ -38,6 +39,7 @@ type CloseDirective = | { action: "auth_required" } | { action: "refresh_authorization" } | { action: "session_expired" } + | { action: "authorization_revoked"; delayMs?: number } | { action: "retry"; delayMs: number } | { action: "give_up" } | { action: "none" }; @@ -97,7 +99,8 @@ export interface UseSessionTransportReturn { */ export function useSessionTransport( sessionId: string, - handlers: SessionTransportHandlers + handlers: SessionTransportHandlers, + enabled = true ): UseSessionTransportReturn { const wsRef = useRef(null); const mountedRef = useRef(true); @@ -257,6 +260,19 @@ export function useSessionTransport( wsTokenRef.current = null; return; + case "authorization_revoked": + wsTokenRef.current = null; + if (!mountedRef.current) return; + if (directive.delayMs === undefined) { + setConnectionError("Authorization could not be refreshed. Please try reconnecting."); + return; + } + reconnectAttempts.current++; + reconnectTimeoutRef.current = setTimeout(() => { + if (mountedRef.current) retry(); + }, directive.delayMs); + return; + case "retry": if (!mountedRef.current) return; reconnectAttempts.current++; @@ -346,6 +362,7 @@ export function useSessionTransport( }, []); const reconnect = useCallback(() => { + if (!enabled) return; // A connect() still awaiting its token must not open a second socket // alongside the one this call creates. invalidateInFlightConnect(); @@ -367,7 +384,7 @@ export function useSessionTransport( setAuthError(null); setConnectionError(null); connect(); - }, [connect, invalidateInFlightConnect]); + }, [connect, enabled, invalidateInFlightConnect]); const markHealthy = useCallback(() => { reconnectAttempts.current = 0; @@ -376,7 +393,7 @@ export function useSessionTransport( // Connect on mount useEffect(() => { mountedRef.current = true; - connect(); + if (enabled) connect(); return () => { mountedRef.current = false; @@ -390,10 +407,11 @@ export function useSessionTransport( discarded.close(); } }; - }, [connect, invalidateInFlightConnect]); + }, [connect, enabled, invalidateInFlightConnect]); // Ping periodically to keep connection alive. useEffect(() => { + if (!enabled) return; const pingInterval = setInterval(() => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: "ping" })); @@ -401,7 +419,7 @@ export function useSessionTransport( }, PING_INTERVAL_MS); return () => clearInterval(pingInterval); - }, []); + }, [enabled]); return { connected, diff --git a/packages/web/src/lib/automation-authorization.test.ts b/packages/web/src/lib/automation-authorization.test.ts new file mode 100644 index 000000000..6b4ad58db --- /dev/null +++ b/packages/web/src/lib/automation-authorization.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import type { EffectiveAuthorization, PermissionId } from "@open-inspect/shared/rbac"; +import { canAccessAutomation } from "./automation-authorization"; + +const CURRENT_USER_ID = "11111111111111111111111111111111"; +const OTHER_USER_ID = "22222222222222222222222222222222"; + +function authorization(permissions: PermissionId[]): EffectiveAuthorization { + return { + userId: CURRENT_USER_ID, + suspendedAt: null, + role: { id: "role-1", key: null, name: "Test" }, + permissions, + }; +} + +describe("canAccessAutomation", () => { + it("allows any scope regardless of ownership", () => { + expect( + canAccessAutomation("automations.manage", authorization(["automations.manage.any"]), { + userId: OTHER_USER_ID, + }) + ).toBe(true); + }); + + it("allows own scope only for the canonical owner", () => { + const auth = authorization(["automations.trigger.own"]); + expect(canAccessAutomation("automations.trigger", auth, { userId: CURRENT_USER_ID })).toBe( + true + ); + expect(canAccessAutomation("automations.trigger", auth, { userId: OTHER_USER_ID })).toBe(false); + expect(canAccessAutomation("automations.trigger", auth, { userId: null })).toBe(false); + }); + + it("denies missing authorization and unrelated capabilities", () => { + expect(canAccessAutomation("automations.manage", null, { userId: CURRENT_USER_ID })).toBe( + false + ); + expect( + canAccessAutomation("automations.manage", authorization(["automations.trigger.any"]), { + userId: CURRENT_USER_ID, + }) + ).toBe(false); + }); +}); diff --git a/packages/web/src/lib/automation-authorization.ts b/packages/web/src/lib/automation-authorization.ts new file mode 100644 index 000000000..798f7e2dc --- /dev/null +++ b/packages/web/src/lib/automation-authorization.ts @@ -0,0 +1,17 @@ +import { + resolveScopedPermission, + type EffectiveAuthorization, + type ScopedPermissionStem, +} from "@open-inspect/shared/rbac"; +import type { Automation } from "@open-inspect/shared/types/automations"; + +/** Checks an automation capability against its canonical owner identity. */ +export function canAccessAutomation( + stem: ScopedPermissionStem, + authorization: EffectiveAuthorization | null, + automation: Pick +): boolean { + if (!authorization) return false; + const scope = resolveScopedPermission(stem, authorization.permissions); + return scope === "any" || (scope === "own" && automation.userId === authorization.userId); +} diff --git a/public/docs/internal/2026-08-28-rbac-design.md b/public/docs/internal/2026-08-28-rbac-design.md new file mode 100644 index 000000000..626d894e1 --- /dev/null +++ b/public/docs/internal/2026-08-28-rbac-design.md @@ -0,0 +1,815 @@ +# Design: Role-Based Access Control + +**Date:** 2026-08-28 + +**Status:** Proposed + +**Research:** [2026-08-28-rbac-research.md](./2026-08-28-rbac-research.md) + +## Summary + +Open-Inspect will add workspace-level RBAC to its existing single-installation identity model. Each +canonical human user is assigned exactly one role. A role contains a set of permissions selected +from a code-owned registry. Four protected built-in roles provide safe defaults. The storage and +resolution model also supports existing custom roles, but custom-role creation and editing are +deferred beyond this foundation. + +Authorization will be enforced in the control plane after authentication and before business logic. +The web will receive effective permissions for navigation and control affordances, but client checks +will remain advisory. Sessions are workspace-wide resources governed by operation permissions, as +specified in +[Workspace-Wide Session Authorization](./2026-08-30-workspace-wide-session-authorization-design.md). +Bot calls will be limited by both a fixed service capability ceiling and, when acting for a human, +that canonical user's current role. + +This design retains one workspace per deployment. It does not add multiple organizations or +per-repository user grants. The SCM App installation continues to define the repository universe; +RBAC determines which application actions a user may perform within that universe. + +## Goals + +- Assign different capability sets to individual canonical users. +- Provide protected Owner, Administrator, Member, and Viewer roles. +- Resolve and assign persisted custom roles from a fixed permission registry. +- Enforce permissions consistently across HTTP routes, session WebSockets, bots, and settings. +- Distinguish authentication, admission, attribution, resource relationships, and authorization. +- Preserve existing installation access during migration without leaving the workspace ownerless. +- Make role assignment and privileged operations durably auditable. +- Apply role changes promptly to new requests and bounded-lifetime live connections. +- Keep the authorization API explicit, typed, testable, and deny-by-default. + +## Non-Goals + +- Multiple workspaces or organizations in one deployment. +- User/group grants for individual repositories or environments. +- Synchronizing roles from GitHub, Google, Slack, Linear, or an identity provider. +- Treating source-control permissions as Open-Inspect roles. +- A general policy language, conditional expressions, deny rules, or arbitrary customer-defined + permission identifiers. +- Billing plans, quotas, approval workflows, or separation-of-duty constraints. +- Modeling Cloudflare, Modal, Terraform, or GitHub deployment operators as application users. +- Changing sandbox-to-control-plane or control-plane-to-Modal machine authentication. +- Making secret values readable after storage. + +## Terminology + +| Term | Meaning | +| --------------------- | --------------------------------------------------------------------------------- | +| Workspace | The singleton administrative boundary represented by one Open-Inspect deployment. | +| Principal | An authenticated human user, first-party service, or session-bound sandbox. | +| Actor | A provider identity asserted by a bot service on behalf of a human. | +| Role | A named collection of registered permissions. | +| Built-in role | A protected role shipped by the application with code-defined permissions. | +| Custom role | A workspace-defined role composed from registered permissions. | +| Permission | A stable `resource.action` identifier checked by backend policy. | +| Relationship | Context such as automation ownership used alongside a scoped permission. | +| Capability ceiling | The maximum permission set a first-party service can exercise. | +| Effective permissions | The permissions produced by the current role, bounded by principal policy. | + +## Decisions + +| Area | Decision | +| ---------------- | ---------------------------------------------------------------------------------------- | +| Tenancy | One implicit workspace per deployment. | +| User assignment | Exactly one role per canonical user. | +| Role model | Four protected built-ins plus custom roles. | +| Permission model | Fixed allow-only registry owned in shared code. Missing permission denies. | +| Enforcement | Control plane is authoritative; web checks are presentation only. | +| Resource scoping | Workspace-wide sessions plus contextual own/any automation actions. | +| Repository scope | SCM installation defines visibility; role permissions govern app operations. | +| Services | Static service ceilings; actor-backed calls use ceiling/actor intersection. | +| Sandboxes | Existing session-bound capability model remains separate from human RBAC. | +| Role changes | Immediate for HTTP; short authorization leases bound live browser connections. | +| Audit | Durable audit events for RBAC changes and sensitive mutations; structured denial logs. | +| Owner bootstrap | Every deployment requires an explicit operator bootstrap after the Owner signs in. | +| Migration | Existing canonical users become Administrator; the operator explicitly bootstraps Owner. | + +## Authorization Model + +### Built-in roles + +The built-in roles are stable system records. Their names and permission sets are defined in code +and cannot be deleted or edited through the application. + +| Role | Intended capability | +| ------------- | --------------------------------------------------------------------------------------------- | +| Owner | Full application access, role management, member management, and ownership transfer. | +| Administrator | Full operational access except ownership transfer and protected Owner changes. | +| Member | Create and operate sessions and automations; use shared targets; no sensitive administration. | +| Viewer | Read shared operational state and session output; no launches or shared-resource mutations. | + +Owner is not represented by a wildcard. It receives every registered permission explicitly when +permissions are resolved. This makes newly introduced permissions visible in review and prevents +custom permission strings from becoming executable. + +### Custom roles + +The data model and permission resolver retain support for persisted custom roles so assignments and +effective authorization do not depend on built-in role keys. This foundation exposes custom roles +through read and assignment APIs only; creating, editing, and deleting them is deferred until there +is a concrete administration workflow. Persisted custom permissions must be registry members, cannot +include `workspace.transfer_ownership`, and remain allow-only without inheritance or deny entries. + +One role per user avoids ambiguous permission union, ordering, and deny precedence. A later group or +multi-role system can expand assignment cardinality without changing permission identifiers or route +checks. + +### Permission registry + +Permissions are exported from `@open-inspect/shared` as stable identifiers and protected built-in +role sets. Built-in policy changes deploy with code and do not require a data migration. Persisted +`role_permissions` rows are the runtime authority only for workspace-defined custom roles. Unknown +identifiers fail role validation and are ignored during effective-permission resolution. Permission +IDs are never reused for different semantics. + +### Permission catalog + +#### Workspace and identity + +| Permission | Actions | +| ------------------------------ | --------------------------------------------------------------------- | +| `workspace.members.read` | List users, identities, roles, and assignment state. | +| `workspace.members.manage` | Assign roles other than Owner; suspend or restore application access. | +| `workspace.roles.read` | List role definitions and permission catalog. | +| `workspace.transfer_ownership` | Assign/remove Owner while preserving at least one Owner. | + +#### Repositories and environments + +| Permission | Actions | +| ------------------------------ | ----------------------------------------------------------------- | +| `repositories.read` | List installed repositories, branches, and metadata. | +| `repositories.use` | Select repositories as session or automation targets. | +| `repositories.settings.manage` | Change repository SCM, sandbox, and integration overrides. | +| `repositories.secrets.manage` | Create, update, or delete repository secrets. | +| `repositories.images.manage` | Toggle or trigger repository image builds. | +| `environments.read` | List and inspect environments and memberships. | +| `environments.use` | Select environments as session or automation targets. | +| `environments.manage` | Create, update, or delete environments and repository membership. | +| `environments.settings.manage` | Change environment integration and sandbox overrides. | +| `environments.secrets.manage` | Create, update, delete, or import environment secrets. | +| `environments.images.manage` | Toggle or trigger environment image builds. | + +#### Sessions + +| Permission | Actions | +| ------------------------- | --------------------------------------------------------------------- | +| `sessions.create` | Create a session using an allowed target. | +| `sessions.read` | Read every workspace session. | +| `sessions.collaborate` | Prompt, attach files, and connect to every workspace session. | +| `sessions.lifecycle` | Rename, archive, unarchive, stop, cancel, and refresh any session. | +| `sessions.delete` | Delete any workspace session. | +| `sessions.sandbox_access` | Obtain terminal, VNC, code-server, or sandbox access for any session. | + +Session creator and participant data are attribution and runtime identity, not authorization. +Read-state changes require `sessions.read` and always mutate only the caller's own read state. + +#### Automations and analytics + +| Permission | Actions | +| ------------------------- | ---------------------------------------------------------------------------- | +| `automations.read` | List automation definitions and run history. | +| `automations.create` | Create an automation with allowed targets and provider mode. | +| `automations.manage.own` | Edit, pause, resume, rotate keys, or delete automations created by the user. | +| `automations.manage.any` | Manage any automation. | +| `automations.trigger.own` | Manually execute an automation created by the user. | +| `automations.trigger.any` | Manually execute any automation. | +| `analytics.read` | View installation-wide session, repository, user, and PR analytics. | + +#### Models, integrations, and execution configuration + +| Permission | Actions | +| --------------------------- | -------------------------------------------------------------------------- | +| `models.preferences.manage` | Change enabled model preferences. | +| `provider_accounts.read` | View provider account metadata, status, and defaults. | +| `provider_accounts.manage` | Connect, reconnect, rename, verify, enable, disable, and default accounts. | +| `integrations.read` | View integration, SCM, sandbox, and commit-signing metadata. | +| `integrations.manage` | Change global integration and sandbox settings. | +| `scm_settings.manage` | Change deployment-wide SCM settings. | +| `commit_signing.manage` | Configure or remove deployment-wide signing material. | +| `global_secrets.manage` | Create, update, or delete global secrets. | +| `image_builds.read` | View repository/environment image build status and history. | + +#### Extensibility + +| Permission | Actions | +| --------------------------- | ------------------------------------------------------------------------- | +| `skills.read` | List shared managed skills. | +| `skills.manage` | Import, edit, assign, reimport, enable, disable, or delete shared skills. | +| `skill_profiles.manage_own` | Manage only the caller's skill profiles. | +| `mcp_servers.read` | List MCP server definitions. | +| `mcp_servers.manage` | Create, update, or delete MCP server definitions. | + +Personal keyboard shortcuts and browser-local appearance require only an authenticated, active user. +They do not need role permissions because they cannot affect another user or shared execution. + +### Default role matrix + +The table groups permissions for readability; the registry stores individual identifiers. + +| Capability group | Owner | Administrator | Member | Viewer | +| -------------------------------------------------------- | :---: | :-----------: | :------: | :----: | +| Workspace, member, role, and audit read | Yes | Yes | No | No | +| Manage members | Yes | Yes | No | No | +| Transfer Owner role | Yes | No | No | No | +| Read repositories and environments | Yes | Yes | Yes | Yes | +| Use repositories and environments | Yes | Yes | Yes | No | +| Read image-build status and history | Yes | Yes | Yes | Yes | +| Manage environments/settings/images | Yes | Yes | No | No | +| Manage global/repository/environment secrets | Yes | Yes | No | No | +| Create sessions | Yes | Yes | Yes | No | +| Read any session | Yes | Yes | Yes | Yes | +| Collaborate in any session | Yes | Yes | Yes | No | +| Perform session lifecycle operations | Yes | Yes | Yes | No | +| Delete sessions | Yes | Yes | Yes | No | +| Obtain sandbox access | Yes | Yes | Yes | No | +| Read automations | Yes | Yes | Yes | Yes | +| Create/manage/trigger automations | Yes | Yes | Own only | No | +| Read analytics | Yes | Yes | Yes | Yes | +| Manage models/provider accounts/integrations/SCM/signing | Yes | Yes | No | No | +| Read shared skills and MCP servers | Yes | Yes | Yes | Yes | +| Manage shared skills and MCP servers | Yes | Yes | No | No | +| Manage own skill profiles | Yes | Yes | Yes | No | +| Manage personal preferences | Yes | Yes | Yes | Yes | + +Viewer receives `sessions.read` but no collaborate or lifecycle permission. Member receives every +non-administrative session operation across the workspace. Administrator preserves the existing +broad operational behavior. + +## Data Model + +### Tables + +```sql +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)) +); + +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); +``` + +Built-in roles have stable `key` values: `owner`, `administrator`, `member`, and `viewer`; their +permission sets come from the shared code registry and have no `role_permissions` rows. Custom roles +have `key = NULL`, and their permission rows are the runtime authority. IDs are opaque; role names +are display values. This foundation does not expose custom-role mutations. + +`users` gains: + +```sql +ALTER TABLE users ADD COLUMN suspended_at INTEGER; +``` + +Suspension records the time access was disabled without deleting identities or historical +attribution. A null value means the user is active. + +Every canonical identity is an active workspace member unless suspended. The RBAC migration seeds +the built-in roles, assigns Administrator to every existing canonical user, and then creates the +default-role trigger. Every identity created afterward receives Member, including identities first +observed through a bot. Identity creation and default role assignment are one database-triggered +workflow. Authorization denies a missing assignment; ordinary sign-in and identity resolution never +repair authorization corruption implicitly. + +Initial ownership is assigned only by the root operator CLI after the intended Owner has signed in +once. The operator supplies the canonical user ID, not an email or browser credential. One temporary +SQL file and one Wrangler D1 execution validate the RBAC schema, unsuspended user, exact assignment, +and absence of another unsuspended Owner before atomically writing a redacted `operator-cli` audit +event and assigning `role_builtin_owner`. The final SQL guard verifies the exact generated audit ID +and aborts the operation if the resulting state is inconsistent. Re-running for the current +unsuspended Owner is a no-op and writes nothing. Ownership changes after initialization use the +authenticated member API. + +### Storage ownership + +- D1 is the source of truth for roles, assignments, status, custom-role grants, and audit events. +- Shared code defines the permission catalog and built-in role grants; persisted permission rows are + the runtime grant authority for custom roles. +- Session creator attribution remains in D1 and is not an authorization relationship. +- Participant attribution remains in the Session Durable Object for message identity, presence, SCM + metadata, and WebSocket tokens. +- No role or permission set is copied into sessions, automations, or provider accounts. + +## Policy Engine + +### Interface + +Authorization is invoked through one control-plane service rather than direct role-table queries in +handlers: + +```ts +type AuthorizationRequest = { + principal: Principal; + permission: PermissionId; + resource?: AuthorizationResource; +}; + +type AuthorizationDecision = { + allowed: boolean; + reason: AuthorizationReason; + actorUserId: string | null; +}; +``` + +The engine exposes `requirePermission()` for ordinary checks and an automation resource helper for +owner-scoped automation policy. Denial throws a typed `403` error with a stable reason code. +Authentication failures remain `401`; missing resources remain `404` after permission admission. + +### Human decision flow + +1. Require an active canonical user. +2. Load the user's role assignment and registered permission set. +3. Deny if no assignment exists. +4. Check the requested permission. +5. For owner-scoped automation permissions, load the automation owner. +6. Return an allow/deny decision with a stable reason. + +### Service decision flow + +Each service has a code-defined ceiling: + +- `web` may proxy browser-auth and discovery operations only; browser application routes authorize + the human user principal produced by composed authentication. +- `github-bot` may read repository/environment launch metadata, create sessions, read, prompt, or + stop workspace sessions, and post GitHub automation events. +- `slack-bot` may read launch catalogs/preferences, create sessions, operate sessions mapped to its + Slack thread, upload/download session media, and post Slack events. +- `linear-bot` may read launch catalogs/preferences, create sessions, and operate sessions mapped to + its Linear issue/agent session. + +For an actor-backed service request: + +```text +effective = service ceiling ∩ actor role permissions +``` + +The actor must resolve to an active canonical user with a role assignment. Service-authenticated +identity enrollment resolves or creates the canonical identity before business authorization and +idempotently assigns the migration default: Administrator for identities captured by the migration, +Member afterward. A first bot interaction can therefore proceed with Member capabilities but can +never claim Owner. Provider webhook verification and GitHub collaborator checks remain additional +admission conditions, never substitutes for application authorization. + +Actorless callbacks, normalized webhook events, and automation triggers use narrow service-only +permissions declared for their exact endpoints. They cannot use broad `user-or-service` management +routes. + +### Sandbox decision flow + +Sandbox authentication remains a scoped capability. A valid sandbox principal can call only route +operations explicitly designated for a sandbox bound to the same session. It does not inherit the +session creator's role and does not gain workspace permissions. Human role changes do not terminate +an executing sandbox, but they can remove human access to its session and controls. + +### Session authorization and identity + +Session operations are workspace-scoped. A user with a session operation permission may apply it to +every session, regardless of creator or participant identity. Deletion is also workspace-scoped. + +`sessions.user_id` retains immutable creator attribution for display, filtering, auditing, and +credential lineage. Session Durable Object participants retain message identity, presence, SCM +metadata, and WebSocket token ownership. Neither is an authorization grant. + +Creating a WebSocket token or sending a prompt requires `sessions.collaborate`. WebSocket +subscription rechecks the represented canonical user's active role and collaboration permission. +Private, invitation-only, participant-restricted, and creator-only session behavior is deferred. + +### Automation execution authority + +Automation definitions retain a canonical owner. Every invocation reauthorizes current state rather +than replaying stored creator authority: + +| Trigger | Initiating actor | Execution principal | Required current authority | +| ------------ | ----------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------- | +| Manual | Requesting user | Requesting user | own/any trigger, target use, session create | +| Schedule | Scheduler service | Automation owner | active owner, manage-own, target use, session create | +| Webhook key | Narrow webhook capability | Automation owner | active owner, manage-own, target use, session create | +| Sentry | Verified Sentry webhook | Automation owner | active owner, manage-own, target use, session create | +| GitHub event | Verified GitHub service actor | Canonical GitHub actor | service ceiling; active actor with session create and target use; active owner with manage-own | +| Slack event | Verified Slack service actor | Canonical Slack actor | service ceiling; active actor with session create and target use; active owner with manage-own | +| Linear event | Verified Linear service actor | Canonical Linear actor | service ceiling; active actor with session create and target use; active owner with manage-own | + +The resulting session is owned by and attributed to the named canonical execution principal. The +initiator, service, and automation owner are recorded separately in invocation/audit metadata. Skill +profiles and user-linked credentials come from the execution principal; installation-wide secrets +and provider accounts remain selected by the automation's current allowed configuration. A manual +trigger never runs as another user's stored identity. Loss of any conjunctive authority marks the +invocation `skipped_authorization` without launching a session. Repeated scheduled or webhook +authorization failures pause the automation after the existing failure threshold and notify +administrators. Provider-account and secret resolution is repeated under the current execution +policy. + +New automations require an active canonical owner. Historical automations with missing or unresolved +owners are disabled during migration and require explicit reassignment by an Administrator or Owner +before execution. + +## Route Enforcement + +### Route metadata + +Authentication policy remains responsible for proving principal kind. Every route declaration also +contains required authorization metadata. Static permission routes declare the permission beside the +method and pattern: + +```ts +authorization: requirePermission("environments.manage"); +``` + +Session routes identify the operation applied to the already-matched path parameter. Conjunctive +policies list every requirement explicitly: + +```ts +authorization: requireAll( + permissionRequirement("sessions.create"), + permissionRequirement("sessions.collaborate") +); +``` + +The router executes declared permission, session-operation, and automation checks before handlers. +Request admission uses current authorization; a concurrent role change does not retroactively revoke +an admitted HTTP request. Personal active-user routes, active global routes, public routes, and +service-only callbacks each use an explicit policy kind; narrow internal callbacks name their exact +service. `router.policy.test.ts` rejects missing metadata, duplicate method/pattern pairs, +incompatible authentication/authorization combinations, and session requirements that reference +absent match groups. + +### Exemptions + +Only these ingress/authentication classes bypass browser authentication: + +- public health; +- browser-auth protocol endpoints; +- externally authenticated webhook ingress; +- image-build capability callbacks; +- session-bound sandbox routes; +- narrow internal service callbacks. + +Each exemption names its alternate ingress mechanism in route metadata. Webhook authenticity permits +normalization/queueing only; every resulting automation or resource operation still applies the +execution-authority policy before side effects. `user-or-service` alone is never sufficient +authorization after this change. + +A generated route-to-policy inventory covers every session, child-session, attachment, media, diff, +pull-request, credential, automation, secret, settings, and callback endpoint. Sandbox child +operations remain parent-session-bound; human child operations use workspace session permissions. + +### Listing and filtering + +Authorization applies before list queries, with contextual automation ownership applied in SQL where +needed. + +- Every user with `sessions.read` receives the workspace session list. +- Creator and Mine filters use `sessions.user_id` as attribution, not access control. +- Automation lists use `manage.any/read` or creator ownership as appropriate. +- Resources requiring a missing read permission are omitted from catalogs and navigation. +- Repository/environment catalogs require read permission; use permission is separately checked when + launching or configuring an execution target. + +## API Contracts + +### Current user authorization + +`GET /me/authorization` returns: + +```json +{ + "userId": "canonical-id", + "suspendedAt": null, + "role": { "id": "role-id", "key": "member", "name": "Member" }, + "permissions": ["repositories.read", "sessions.create"] +} +``` + +This endpoint is available only to the current browser user. Responses are private and no-store. + +### Role administration + +| Method | Path | Permission | Purpose | +| ------ | ------------ | ---------------------- | ------------------------------------ | +| `GET` | `/roles` | `workspace.roles.read` | List roles, counts, and permissions. | +| `GET` | `/roles/:id` | `workspace.roles.read` | Read one role and permissions. | + +### Member administration + +| Method | Path | Permission | Purpose | +| ------ | ------------------------- | -------------------------------------- | ------------------------------------- | +| `GET` | `/members` | `workspace.members.read` | List canonical users and assignments. | +| `PUT` | `/members/:userId/role` | `workspace.members.manage` or transfer | Replace one assignment. | +| `PUT` | `/members/:userId/status` | `workspace.members.manage` | Suspend or restore access. | + +Owner assignment or removal requires `workspace.transfer_ownership`, including when the caller also +has member-management permission. Suspending, deleting, or merging an Owner also requires transfer +permission. Every role/status/delete/merge mutation uses guarded SQL that succeeds only if another +unsuspended Owner remains in the same D1 batch. User deletion is blocked by assignment +`ON DELETE RESTRICT`; the assignment can be removed only through this guarded membership service. +User merge requires an explicit surviving assignment, repoints canonical session creator +attribution, and preserves both immutable audit snapshots. + +Assignment and status updates apply the request-scoped authorization decision and preserve Owner +invariants in the same D1 batch as the mutation. Authorization changes do not retroactively revoke +an already admitted request. + +### Error contract + +Forbidden API responses use: + +```json +{ + "error": "Forbidden", + "code": "permission_required", + "permission": "environments.manage" +} +``` + +Other denials use codes such as `active_user_required` and `service_capability_required`. Responses +do not disclose another user's role. + +## Web Experience + +### Authorization state + +The app shell loads current authorization with the browser session. It distinguishes: + +- unauthenticated; +- authenticated but suspended/unassigned; +- authenticated and authorized; +- authorization service unavailable. + +Permission checks consume the stable `hasPermission` predicate from the current-user authorization +hook. They hide navigation that has no readable content and disable contextual controls when +explaining the missing capability is useful. Server-rendered session pages authorize before fetching +snapshots. + +### Members and roles + +A Workspace settings section contains: + +- Members: identity, provider links, status, role, last activity, and assignment actions. +- Roles: built-in/custom roles, assignment count, and categorized permission details. +- Audit log: actor, action, target, outcome, reason, and timestamp. + +The UI prevents removing the last unsuspended Owner and assigning Owner without transfer permission. +The API repeats every invariant. + +### Existing navigation + +- Settings tabs appear only when at least one permission makes them useful. +- New session requires `sessions.create` plus target `use` permission. +- All/Mine becomes All/My sessions; both are filters over the workspace-wide session list. +- Session controls reflect read, collaborate, lifecycle, delete, and sandbox-access permissions + independently. +- Analytics requires `analytics.read`. +- Automation create/manage actions are independent from automation read access. + +The browser never treats hidden controls or downloaded permissions as security enforcement. + +## Audit and Observability + +Durable audit events are required for: + +- user role assignment; +- access suspension/restoration; +- Owner assignment/removal; +- secret, provider-account, commit-signing, integration, SCM, MCP, and shared-skill mutations; +- allowed and denied member-management operations. + +Pure D1 mutations write the audit event in the same D1 batch. + +High-volume ordinary reads and successful session messages remain in structured request logs rather +than D1 audit storage. Every authorization denial logs principal kind, actor user ID when known, +permission, policy, resource type, opaque resource ID, reason code, request ID, and service name. +Secret values, OAuth credentials, prompt content, and signed tokens never enter audit metadata. + +Metrics include denial count by permission/reason/principal, unassigned active users, assignment +count by role, and authorization latency. + +## Role Changes and Revocation + +- HTTP requests load current assignment/status and apply changes immediately. +- Role permission edits take effect on the next authorization lookup. +- Browser WebSocket credentials are bound to the canonical user. Subscribe verifies current D1 + authorization and rejects missing or suspended users, missing role assignments, and unavailable + authorization storage. +- A successful subscribe asks the WebSocket manager to grant a five-minute wall-clock authorization + lease. The manager persists its expiry in `ws_client_mapping` and owns earliest-expiry scheduling + in the unified alarm. On expiry the browser clears its credential and reconnects through the + authorized HTTP token route. +- Alarm and hibernation restoration close every expired connection even when it is idle. Every + inbound event and outbound broadcast also rejects expired leases as defense in depth. A role + change therefore revokes live browser access within the five-minute wall-clock lease bound. +- Bot calls authorize on every signed HTTP request. Stale Slack/Linear issue mappings do not bypass + current policy. +- Suspending a user invalidates Better Auth sessions. +- Existing sandboxes continue running because their credentials represent the session runtime, not + the user. Users who lose lifecycle permission cannot reconnect or control them. + +## Migration and Compatibility + +The migration is additive and preserves current capability for every canonical user: + +1. Create role, permission, assignment, and audit tables. +2. Insert protected built-in role records; their permission sets remain code-owned. +3. Assign Administrator to every canonical user present in `users`, including identities originally + created through Slack, GitHub, or Linear. +4. Create the unconditional default-role trigger. Identity provisioning after this point assigns + Member. + +No route switches to enforcement until every existing canonical user has an Administrator assignment +and built-in role reconciliation succeeds. Administrators may continue using the application before +Owner bootstrap. After deployment, the intended Owner signs in once to create a canonical user and +assignment. An operator then dry-runs and executes the root CLI against that canonical ID. Sign-in +and bot identity creation never assign Owner. + +Deployment documentation will state that Administrator preserves the previous installation-wide +operational behavior, while Member becomes the default for newly admitted users. + +### Operator bootstrap + +Terraform exports the D1 database name but does not configure an Owner identity. The supported +sequence is deploy, have the intended Owner sign in once, obtain the canonical ID from the browser +session, run `npm run rbac:bootstrap-owner -- --database --user `, review the dry-run +preflight, rerun with `--execute`, and verify `/health` reports `rbac.ownerAssignment=present`. + +When an unsuspended Owner assignment exists, `/health` reports `rbac.ownerAssignment=present`; when +none exists, it reports `missing`. Administrators and Members can use their existing capabilities, +but no one can exercise Owner-only actions. + +## Failure Handling + +- D1 authorization lookup failure denies the request and returns `503 authorization_unavailable`; it + never falls back to broad authenticated access. +- Missing or unknown role permissions deny and emit a reconciliation error. +- Missing user assignment denies shared application routes but permits sign-out and own identity + discovery so an administrator can repair access. +- Audit-write failure aborts transactional D1 administration. +- Web authorization metadata failure renders an unavailable state rather than the unrestricted app. + +## Security Invariants + +1. Authentication never implies authorization. +2. Admission allowlists never imply a role beyond bootstrap/default assignment. +3. Unknown permissions, missing assignments, suspended users, and policy errors deny access. +4. Client-side permission checks are never authoritative. +5. Creator and participant attribution are not authorization checks. +6. A service cannot exceed its code-defined ceiling. +7. An actor-backed service cannot exceed the linked user's current permissions. +8. An actorless service can execute only exact service-only operations. +9. Sandbox credentials remain bound to one session and confer no workspace role. +10. Before bootstrap, no user can exercise Owner-only actions; after bootstrap, at least one + unsuspended Owner always exists. +11. Only an Owner can add or remove Owner assignments. +12. Role changes and privileged mutations produce durable, redacted audit events. +13. Session lists require workspace read permission before returning metadata. +14. Secret-management permission never makes stored secret values readable. +15. External provider authorization is additional evidence, not a replacement for application RBAC. + +## Testing Strategy + +### Shared + +- Permission registry uniqueness and stable serialization. +- Built-in role snapshots and persisted custom-role resolution. +- API schema rejection of malformed role responses and assignments. + +### Control-plane unit + +- Human permission allow/deny matrix for every built-in role. +- Custom role resolution, suspension, missing assignment, and unknown permission behavior. +- Workspace-wide session operation permissions for every built-in role. +- Service ceiling and actor intersection for every bot. +- Actorless exact-endpoint service permissions. +- Last-Owner, built-in-role, assignment, and transaction invariants. +- Concurrent Owner demotion/suspension/delete and user-merge conflicts. +- Stable `401`, `403`, `404`, and `503` behavior. +- Route policy completeness requiring authorization metadata or named exemption. + +### Control-plane integration + +- Multi-user tests proving permitted Members can read, collaborate, manage lifecycle, access the + sandbox, and delete across workspace sessions. +- Viewer can read but cannot prompt, launch, stop, delete, or access sandbox credentials. +- Administrator can operate installation-wide resources but cannot transfer Owner. +- Owner can assign roles without removing the last unsuspended Owner. +- Secret/settings/provider-account/skill/MCP/image routes enforce individual permissions. +- Session lists remain workspace-wide while creator and Mine filters preserve attribution semantics. +- Role changes are enforced when idle, active, hibernated, and multi-tab WebSocket authorization + leases expire. +- Suspended browser sessions and bot actors are denied. +- D1 failure fails closed and audit failure aborts protected mutations. +- Automation schedule, webhook, event, and manual triggers reauthorize the correct execution + principal after owner suspension, demotion, role edit, and target-access loss. +- Sentry, GitHub, Slack, and Linear trigger tests assert session owner, initiator audit fields, + owner guard, service ceiling, actor permission intersection, and credential/profile source. + +### Web + +- Navigation and controls for Owner, Administrator, Member, Viewer, custom, suspended, and + unavailable states. +- Direct URL access remains denied when navigation is hidden. +- Session server rendering does not fetch unauthorized snapshots. +- Workspace member controls enforce API invariants. +- Generic forbidden responses do not trigger sign-in flows. + +### Bots + +- Each service can call only its ceiling routes. +- Linked actor role is required for actor-backed launches and prompts. +- Unlinked, suspended, and underprivileged actors fail closed with user-safe provider responses. +- Existing GitHub collaborator, Slack webhook, and Linear organization checks remain enforced. +- External session mappings cannot bypass actor role or service ceiling checks. + +### Migration + +- Empty installation assigns Member to new identities and requires an explicit canonical-ID operator + bootstrap for the initial Owner. +- Existing installation assigns every pre-migration canonical user Administrator, including bot-only + identities, then requires the same explicit operator bootstrap. +- Every canonical user receives exactly one assignment. +- Built-in role reconciliation is idempotent and rejects incompatible registry drift. +- Exact migration SQL executes under workerd/D1, including indexes and constraints. +- Better Auth or bot identity creation followed by assignment failure cannot enter business routes + and retries Member assignment idempotently. +- Owner bootstrap requires an existing unsuspended canonical user with exactly one assignment and + refuses another unsuspended Owner. +- CLI bootstrap is atomic and idempotent, writes exactly one redacted operator audit event on a + ready transition, and writes nothing when the target is already the current Owner. + +## Alternatives Considered + +### Role column on `users` + +Rejected because it cannot represent custom role metadata and permission composition without +hard-coding authorization throughout handlers. + +### Multiple roles per user + +Rejected for the initial system because role union and future deny semantics add complexity without +a current user requirement. One assignment directly matches user-level role configuration. + +### Per-repository and per-environment grants + +Deferred because current deployment identity and repository discovery are installation-wide. Adding +resource grants would require group semantics, environment membership rules, bot grant mapping, and +SCM synchronization decisions not resolved by current product behavior. + +### Encode permissions in browser sessions + +Rejected because role changes would remain stale for the Better Auth session lifetime and backend +handlers would still need authoritative policy state. + +### Use Session Durable Object participant roles as application RBAC + +Rejected because those roles exist only inside one session, are auto-created by current workflows, +and cannot govern installation settings or repository/environment actions. + +### External policy engine + +Rejected because the initial policy consists of a small fixed permission registry plus contextual +automation ownership. D1 and typed control-plane policy keep the trust boundary and operational +footprint within the existing architecture. + +## Open Product Decisions + +The design chooses defaults for implementation, but product confirmation is required before +enforcement: + +1. Session operations are workspace-wide when granted by the user's role. +2. New canonical users default to Member after the RBAC migration boundary. +3. Administrator receives all operational permissions except ownership transfer. +4. Persisted custom roles cannot receive ownership transfer. +5. Repository and environment access remains installation-wide rather than user-granted. +6. Existing users are promoted to Administrator to preserve current access. +7. Executing sandboxes continue after their creator is suspended or demoted. +8. Authorization audit events are retained under the deployment's existing D1 retention policy. +9. Scheduled/webhook automations stop launching when their owner loses current execution authority. +10. Session creator and participant identities are attribution, not authorization. +11. Five minutes is a strict wall-clock browser WebSocket revocation bound, including idle sockets. diff --git a/public/docs/internal/2026-08-28-rbac-research.md b/public/docs/internal/2026-08-28-rbac-research.md new file mode 100644 index 000000000..8384e78bb --- /dev/null +++ b/public/docs/internal/2026-08-28-rbac-research.md @@ -0,0 +1,386 @@ +# Research: Role-Based Access Control + +**Date:** 2026-08-28 + +**Status:** Superseded research snapshot + +**Scope:** Current identity, authentication, authorization, resources, actions, storage, user +workflows, service integrations, and operational trust boundaries relevant to application RBAC. + +The implemented model is documented in [Role-Based Access Control](./2026-08-28-rbac-design.md). + +This document is intentionally research-only. It does not include recommendations, implementation +plans, proposed code/API/schema changes, task breakdowns, estimates, or rollout steps. + +## Summary + +Open-Inspect authenticates human users, first-party services, and session-bound sandboxes, but it +does not have an application role, workspace membership, permission, grant, or administrator model. +The deployment is explicitly single-tenant: admission policy determines who may sign in, and an +admitted human generally shares installation-wide access to repositories, sessions, environments, +secrets, settings, provider accounts, automations, skills, MCP servers, image controls, and +analytics. + +Human identity is canonicalized across GitHub, Google, Slack, and Linear. First-party bots sign +requests as distinct services and may assert actors in their own provider namespace. Sandboxes use +credentials bound to one session. These principal distinctions constrain authentication channels, +but most route policies do not distinguish capabilities among admitted humans or among signed bot +services. + +Sessions contain `owner` and `member` participants, but those roles are not a general authorization +boundary. Session creator fields primarily support attribution and filtering. Existing visibility +logic deliberately returns any session in the installation, and authenticated users or services can +join, prompt, inspect, stop, or mutate many sessions without an owner check. + +The application has three broad resource scopes today: per-user preferences, session-scoped runtime +state, and installation-wide operational resources. Repository and environment resources do not have +application membership or grant records. External source-control permissions are consulted in some +GitHub bot trigger paths, but ordinary web and service access uses the deployment's SCM App or token +authority. + +## Research Questions + +1. Which identities and authentication channels exist today? +2. Which application resources and actions would intersect with authorization decisions? +3. Which resources are personal, session-scoped, repository/environment-scoped, or + installation-wide? +4. Where are authorization decisions currently made, and what do they enforce? +5. How do Slack, GitHub, Linear, sandboxes, and deployment operators cross trust boundaries? +6. Which current fields represent attribution rather than ownership or access? +7. Which gaps and unresolved product semantics affect an RBAC design? + +## Current Behavior + +### Human identity and admission + +- Canonical users are stored in D1 `users`; provider identities are stored in `user_identities` and + linked by canonical user ID. +- Browser sign-in supports GitHub and Google through Better Auth. Browser requests reach the control + plane through a signed `service:web` channel and a valid browser session cookie. +- Admission supports GitHub login, email, email domain, and GitHub organization allowlists, plus an + explicit unsafe allow-all mode. Admission only controls sign-in eligibility. +- The browser session contract exposes user ID, name, email, and image. It has no role, permission, + membership, workspace, or resource-grant data. +- Canonical user IDs currently scope keyboard shortcuts, managed-skill profiles, session read state, + temporary provider-account authorization transactions, and the session-list `Mine` filter. + +### Request principals and route policies + +The control plane resolves every authenticated request to one of: + +| Principal | Identity boundary | Current use | +| ------------------- | ----------------------------------------- | -------------------------------------------------- | +| Human user | Canonical user ID | Browser-originated application requests | +| First-party service | Service name plus optional asserted actor | Web, Slack, GitHub, and Linear Workers | +| Sandbox | Session ID | Session runtime callbacks and credential brokerage | + +Route authentication distinguishes public, handler-authenticated, web-service, human-user, +user-or-service, sandbox, and sandbox-fallback requests. It does not express application actions, +resource scopes, user roles, or grants. Human-only routes exclude bots but admit every authenticated +human. Most `user-or-service` routes admit every signed first-party service, not a named subset. + +### Session visibility and participation + +- Session creation stores a canonical creator in the D1 session index and creates a Durable Object + participant with role `owner`. +- Other identities are added as `member` participants when they request a WebSocket token or send a + prompt. +- `SessionIndexStore.getVisibleForUser()` deliberately ignores the supplied user ID and returns any + existing session. Its source comment names this the single-tenant visibility boundary. +- Session lists are global unless `createdBy=me` is supplied as an explicit filter. +- Session title, archive, and unarchive handlers require participation, but do not distinguish + `owner` from `member`. Other lifecycle and runtime routes do not consistently require existing + participation. +- An authenticated user or asserted service actor can request a WebSocket token for a session and be + added as a member. Prompt submission follows the same auto-membership pattern. +- Deletion, stop, event, artifact, media, attachment, participant, pull-request, and other session + operations generally rely on route authentication and a supplied session ID rather than creator or + participant ownership. +- Sandbox credentials are verified against the Session Durable Object and cannot authenticate to a + different session. Child-sandbox fallbacks are also bound to their parent session. + +### Installation-wide resources + +The following resources are shared across admitted users in the current deployment model: + +| Resource | Read actions | Mutation or execution actions | +| ------------------------ | ------------------------------------------- | ------------------------------------------------------------ | +| Repository catalog | List repositories, branches, metadata | Use as session/environment/automation targets | +| Global secrets | List key metadata | Create/update/delete values | +| Repository secrets | List key metadata | Create/update/delete values | +| Environments | List/view | Create/update/delete; manage repositories and branches | +| Environment secrets | List key metadata | Create/update/delete/import values | +| Integration settings | View global/repository/environment settings | Enable, update, override, reset | +| SCM and sandbox settings | View configuration | Update/reset defaults and overrides | +| Model preferences | View enabled models | Change installation-wide model visibility | +| Provider accounts | List/status | Connect, reconnect, rename, verify, enable, disable, default | +| Automations | List/view runs | Create, edit, trigger, pause, resume, delete, rotate key | +| Managed shared skills | List/view | Import, edit, assign, reimport, delete | +| MCP servers | List/view | Create, edit, delete commands, headers, and environment | +| Image builds | View status/feed | Toggle prebuilds, trigger builds | +| Commit signing | View metadata | Configure/update/delete signing material | +| Analytics | View installation aggregates | No primary mutation workflow | + +Environments have no owner, member, team, role, or ACL columns. Repository access is based on the +deployment's SCM App installation or configured token. Generic settings and secret stores are not +keyed by user. Provider-account creator/updater IDs and automation creator fields record attribution +but do not restrict later access. + +### Personal and local resources + +- Keyboard shortcut preferences are stored by canonical user ID. +- Managed-skill profiles are associated with a canonical user, while the shared skill catalog is + installation-wide. +- Session read states are stored by `(user_id, session_id)` but rely on the broad session visibility + boundary. +- Provider-account device-authorization transactions are user-scoped while in progress; completed + provider accounts are installation-wide. +- Appearance and syntax preferences are browser-local. +- Slack and Linear bot preferences are provider-user-scoped in their Workers' KV stores. + +### Web application behavior + +- `AppAuthBoundary` gates the application shell on authentication state only. +- The sidebar exposes new session, all/mine sessions, settings, automations, analytics, and archived + sessions to every authenticated user. +- Settings navigation is identical for all authenticated users except for deployment-capability + checks such as repository-image support. +- Session controls react to lifecycle, connection, and loading state, not participant role. +- No client condition was found for an administrator flag, role, permission list, repository grant, + environment membership, session owner role, or creator equality. +- The client does not currently represent an authenticated-but-forbidden state distinct from sign-in + admission denial, aside from generic API errors. + +## Relevant Workflows + +### Browser request + +1. GitHub or Google OAuth establishes a Better Auth browser session. +2. The Next.js server signs the control-plane request as `service:web` and forwards the browser + cookie. +3. The control plane verifies both channel and browser identity and creates a user principal. +4. The route policy checks principal kind and SCM compatibility. +5. The handler reads or mutates the requested resource; most handlers have no additional user-level + access check. + +### Bot-created session + +1. A bot verifies an external Slack, GitHub, or Linear webhook. +2. The bot signs a control-plane request with its per-service secret and may assert the external + actor in its namespace. +3. The control plane verifies the service and actor namespace, resolves or creates a canonical user, + and derives session identity from the principal. +4. Session creation requires an actor-backed participant. Existing-session prompts may be actorless + and are then attributed to `anonymous`. +5. The selected repository or environment is resolved using deployment-wide catalogs and + credentials. GitHub trigger flows additionally enforce configured allowlists or GitHub + write-level collaborator permissions; Slack and Linear do not perform equivalent SCM-user checks. + +### Session collaboration + +1. A browser or bot addresses a session by ID. +2. A WebSocket-token or prompt request can create a `member` participant automatically. +3. The Session Durable Object stores participants, messages, artifacts, diffs, repositories, sandbox + state, and credentials. +4. Participant role is returned in shared session types, but the web does not consume it as an + authorization signal. + +### Sandbox runtime + +1. The control plane creates and hashes a per-session sandbox token. +2. The token and session configuration are injected into the sandbox. +3. Sandbox requests are authenticated against the session ID in the route. +4. Session-bound routes broker SCM credentials, provider access, commit signing, skills, + attachments, and runtime events. +5. The sandbox is not represented as a human role and cannot authenticate outside its bound session + through the sandbox credential. + +### Deployment and data plane + +1. GitHub Actions and Terraform provision Cloudflare, D1, R2, Workers, service secrets, and Modal. +2. Deployment operators hold authority outside the application's principal model through source + control, GitHub environments, Cloudflare, Terraform state, Modal, and SCM App installation + access. +3. The control plane authenticates to Modal with a deployment-wide HMAC secret. +4. Modal trusts possession of that secret for authenticated endpoints and does not receive the + initiating application user, role, or resource grants. + +## Existing Patterns + +### Central authentication composition + +The router attaches a verified principal before authenticated handlers run. Route definitions carry +typed authentication policy, and policy-completeness tests assert that every route declares one. + +### Canonical cross-provider identity + +Browser and bot identities converge on a canonical D1 user while retaining provider identity and +participant identity. Body-supplied identity and credential fields are rejected for +identity-sensitive routes. + +### Session-bound capabilities + +Sandbox tokens, image-build callback tokens, and browser participant WebSocket tokens are scoped to +specific runtime resources rather than functioning as installation-wide human credentials. + +### Provider and scope registries + +Repositories use shared identity helpers, environments have opaque IDs and ordered repository +membership, image builds use explicit repository/environment scope kinds, and integration settings +already resolve global, repository, and environment levels. + +### Attribution without authorization + +Sessions, automations, provider accounts, skills, and logs record creators or actors. Existing code +and design documents explicitly distinguish these fields from ownership checks. + +### Denial and audit behavior + +Authentication failures use `401`; principal-kind failures use `403`. Some sensitive workflows, +including managed skills and Slack notification, emit structured audit logs. There is no complete, +durable application authorization audit ledger. + +## Constraints and Invariants + +- TypeScript and Python use milliseconds and seconds respectively for durations. +- Shared contracts are consumed by control plane, web, and bot packages and are built first. +- D1 is the installation-wide relational store; each Session Durable Object has separate SQLite + state and is not directly joinable with D1 during an in-object operation. +- Route authentication happens before handler execution; handler-authenticated webhooks apply their + own provider or capability checks. +- Browser requests must retain both a signed web-service channel and a valid browser session. +- Bot actors can only be asserted by their owning first-party service namespace. +- Caller-supplied identity fields are rejected where verified principal identity is required. +- Sandbox credentials remain session-bound and session provider-auth choices are immutable after + creation. +- Repository owners may contain nested path segments; repository identity helpers split on the last + slash and preserve the complete owner. +- Environment sessions snapshot repository membership; later environment changes do not alter + existing sessions. +- Secrets are encrypted at rest and values are not returned by list operations, but authorization to + manage their ciphertext and metadata is installation-wide. +- The Modal API receives a deployment credential, not end-user identity; application authorization + currently terminates at the control plane. +- Existing admitted users have broad access under documented single-tenant semantics. + +## Known Gaps and Risks + +- No role, membership, grant, group, workspace, or administrator records exist in D1. +- No authorization action vocabulary or resource-scope vocabulary exists in shared contracts. +- Route policies conflate authentication channel, principal kind, SCM support, and broad route + access; handlers apply resource checks inconsistently. +- `GITHUB_USER_OR_SERVICE_ROUTE` and similar policies often admit all signed services despite their + names. +- Session `owner/member` roles do not define owner-exclusive actions and do not govern most access. +- Session creator, provider-account creator, automation creator, and updater fields can be mistaken + for authorization ownership despite current attribution-only behavior. +- The repository catalog reflects installation authority rather than authenticated-user grants. +- A repository can belong to multiple environments, and environments can contain multiple + repositories; current data has no rules for combining access at those boundaries. +- Bots differ in external authorization evidence. GitHub has repository permission checks in trigger + flows, while Slack and Linear rely primarily on webhook authenticity, configured mappings, and + deployment catalogs. +- Service credentials provide broad route-family capabilities and are not generally constrained by + actor, creator, repository, or session. +- The web exposes navigation and controls before knowing whether an action could be forbidden. +- There is no complete durable record of allow/deny decisions, policy changes, role assignment, or + access revocation. +- Existing tests primarily distinguish authenticated from unauthenticated requests, not multiple + human capability levels or cross-user denial. +- Long-lived sessions, WebSockets, bot mappings, and sandboxes can outlast changes to human access; + current code has no access-revocation lifecycle because access grants do not exist. +- External operator authority is outside the application and cannot be represented by current + principals. + +## Open Questions + +1. Does one Open-Inspect installation correspond permanently to one workspace, or can an + installation contain multiple independently administered organizations? +2. Are application roles intended to be fixed built-in roles, configurable custom roles, or both? +3. Which role bootstraps the first deployment administrator, and how is loss of all administrators + recovered? +4. Are repository permissions inherited solely from an application role, assigned per user/group, + synchronized from SCM, or combined from those sources? +5. Are environments independent authorization resources or derived from access to all, any, or the + primary member repository? +6. Are sessions private to creators by default, visible to users with target access, or visible to + the whole workspace? +7. Which session actions differ among creator, participant owner, participant member, repository + maintainer, and workspace administrator? +8. Does adding a participant grant access, or merely record collaboration after another policy has + admitted access? +9. Do automation runs and child sessions inherit access from the automation owner, triggering actor, + target resource, parent session, or a service identity? +10. Which first-party services may read or mutate installation settings, secrets, provider accounts, + and arbitrary sessions? +11. Do bots act with service-owned capabilities, the asserted human actor's capabilities, or an + intersection of both under the intended product semantics? +12. How are actors without a linked canonical user handled when authorization requires user-level + grants? +13. Is viewing secret key metadata distinct from writing or deleting secret values? +14. Are analytics, user directories, audit records, and usage/cost data separate administrative + capabilities? +15. Which role and grant changes must revoke active WebSockets, bot thread mappings, sandbox access, + or in-flight provider authorization transactions? +16. Which authorization changes require historical audit retention, and for how long? +17. Must existing admitted users preserve their current broad access when role records first appear? +18. Are deployment operators expected to be application administrators, or are these intentionally + separate authority domains? + +## Evidence + +- `packages/control-plane/src/auth/principal.ts`: defines user, service, and sandbox principals and + service actor-namespace rights. +- `packages/control-plane/src/auth/authenticate.ts`: composes signed web-service and browser-session + authentication. +- `packages/control-plane/src/auth/identity-enforcement.ts`: derives actor identity and rejects + caller-supplied identity fields. +- `packages/control-plane/src/auth/user/admission-policy.ts`: defines sign-in admission rules. +- `packages/control-plane/src/db/user-store.ts`: canonicalizes provider identities into users. +- `packages/control-plane/src/routes/shared.ts`: defines route authentication and SCM policies. +- `packages/control-plane/src/router.ts`: attaches principals and enforces principal-kind policies. +- `packages/control-plane/src/db/session-index.ts`: implements installation-wide session visibility. +- `packages/control-plane/src/routes/session-index.ts`: lists and deletes sessions and stores + per-user read state. +- `packages/control-plane/src/routes/session-runtime-proxy.ts`: exposes session runtime actions. +- `packages/control-plane/src/routes/session-ws-token.ts`: mints participant WebSocket credentials. +- `packages/control-plane/src/routes/session-prompt.ts`: derives prompt authors and allows automatic + session participation. +- `packages/control-plane/src/session/schema.ts`: stores Session Durable Object participants and + runtime state. +- `packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts`: checks + participation for selected lifecycle mutations. +- `packages/shared/src/types/sessions.ts`: defines `owner/member` participant roles. +- `packages/web/src/lib/browser-auth-session-contract.ts`: exposes browser user identity without + authorization data. +- `packages/web/src/components/app-auth-boundary.tsx`: gates the application on authentication. +- `packages/web/src/components/session-sidebar.tsx`: exposes shared navigation and All/Mine filters. +- `packages/web/src/components/settings/settings-nav.tsx`: exposes installation settings without + user-role filtering. +- `packages/control-plane/src/routes/repos.ts`: lists repositories using deployment SCM authority. +- `packages/control-plane/src/routes/environments.ts`: exposes installation-wide environment CRUD. +- `packages/control-plane/src/routes/secrets.ts`: exposes global and repository secret management. +- `packages/control-plane/src/routes/environment-secrets.ts`: exposes environment secret management. +- `packages/control-plane/src/routes/integration-settings.ts`: manages global, repository, and + environment settings. +- `packages/control-plane/src/routes/model-provider-accounts.ts`: manages installation-wide provider + accounts with human-only authentication. +- `packages/control-plane/src/routes/automations.ts`: exposes shared automation lifecycle actions. +- `packages/control-plane/src/routes/skills.ts`: separates shared skill administration from per-user + profiles. +- `packages/control-plane/src/routes/mcp-servers.ts`: exposes shared MCP server management. +- `packages/control-plane/src/routes/analytics.ts`: exposes installation-wide analytics. +- `terraform/d1/migrations/0019_create_users.sql`: creates canonical users and attribution columns. +- `terraform/d1/migrations/0033_environments.sql`: creates environments without ownership or grants. +- `terraform/d1/migrations/0055_session_read_states.sql`: creates per-user session read state. +- `docs/HOW_IT_WORKS.md`: documents the single-tenant security and repository-access model. +- `provider-accounts.md`: explicitly treats creator/updater fields as audit metadata and provider + accounts as installation-wide. +- `packages/slack-bot/src/sessions/control-plane-client.ts`: sends signed Slack actor session calls. +- `packages/github-bot/src/handlers.ts`: applies GitHub trigger and sender authorization checks. +- `packages/linear-bot/src/webhook-handler.ts`: resolves Linear actors and session targets. +- `packages/control-plane/src/sandbox/client.ts`: authenticates deployment-wide control-plane calls + to Modal. +- `packages/control-plane/src/router.policy.test.ts`: checks route authentication policy coverage. +- `packages/control-plane/test/integration/ws-token-participants.test.ts`: verifies automatic member + creation. diff --git a/public/docs/internal/2026-08-30-session-access-research.md b/public/docs/internal/2026-08-30-session-access-research.md new file mode 100644 index 000000000..7249aca01 --- /dev/null +++ b/public/docs/internal/2026-08-30-session-access-research.md @@ -0,0 +1,407 @@ +# Research: Session Access and Contribution + +**Date:** 2026-08-30 **Status:** Superseded current-state snapshot **Scope:** Session permission, +relationship, participant, listing, and WebSocket behavior before workspace-wide session +authorization was adopted. + +This document is intentionally research-only. It does not include recommendations, implementation +plans, proposed code/API/schema changes, task breakdowns, estimates, or rollout steps. + +The accepted replacement is +[Workspace-Wide Session Authorization](./2026-08-30-workspace-wide-session-authorization-design.md). + +## Summary + +The current system does not generally require a user to be a session creator or participant before +they can read or contribute to a session. Built-in Members receive `sessions.read.any` and +`sessions.collaborate.any`; Viewers receive `sessions.read.any`. These `any` permissions bypass the +`session_access` relationship table entirely. An unrelated Member can therefore list, read, prompt, +upload collaborative artifacts, and request a WebSocket token for any workspace session. + +`session_access` remains active in narrower workflows. It gates Member lifecycle and sandbox access, +requires creator status for Member deletion and participant management, supports custom roles that +hold only `.own` permissions, filters own-scoped lists, and constrains every actor-backed bot call +because service actors are forced to `own` scope. WebSocket subscription also consults it when the +user's collaboration permission resolves to `.own`. + +The system also has a separate Session Durable Object `participants` table. It stores session-local +identity, SCM metadata, WebSocket tokens, presence identity, and an `owner` or `member` role. It is +not the authority used by `requireSession`, but title, archive, and unarchive still require the +caller to exist in that table. D1 relationships and Durable Object participants can therefore +diverge and have different effects. + +The resulting complexity represents several different concerns under similar terminology rather than +one uniform contribution boundary. + +## Research Questions + +1. Does session access currently restrict who can read or contribute to a session? +2. Which operations still depend on creator or participant relationships? +3. What does `requireSession` enforce for humans, services, and sandboxes? +4. How do D1 `session_access` and Durable Object participants differ? +5. Which current behaviors and documents are inconsistent or ambiguous? + +## Current Behavior + +### Built-in role behavior + +The built-in role registry gives Members these session permissions: + +- `sessions.read.any` +- `sessions.collaborate.any` +- `sessions.lifecycle.own` +- `sessions.participants.manage.own` +- `sessions.delete.own` +- `sessions.sandbox_access.own` + +Viewers receive `sessions.read.any` and no contribution or lifecycle permission. Administrators and +Owners receive the `any` form of every session operation. + +`resolveScopedPermission()` selects `any` before `own`. The router does not query a session +relationship after resolving `any`. + +Consequences for a built-in Member: + +| Operation | Existing relationship required? | Current basis | +| ------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------- | +| List sessions | No | `sessions.read.any` | +| Read session state, messages, artifacts, media, diffs, and children | No | `sessions.read.any` | +| Submit an HTTP prompt | No | `sessions.collaborate.any` | +| Request a WebSocket token | No | `sessions.collaborate.any` | +| Upload attachments, media, or diffs | No | `sessions.collaborate.any` | +| Create a pull request or child session | No prior relationship | `sessions.collaborate.any`, plus operation-specific requirements | +| Stop, rename, archive, unarchive, refresh, or retry | Yes | `sessions.lifecycle.own` | +| Obtain sandbox credentials | Yes | `sessions.sandbox_access.own` | +| Delete a session | Creator only | `sessions.delete.own` | +| Manage participants | Creator only | `sessions.participants.manage.own` | + +An Administrator or Owner bypasses these relationship requirements through the corresponding `*.any` +permission at the router layer. + +### Operation-to-relationship mapping + +`session-authorization-policy.ts` maps each operation to both a permission stem and an own-scope +relationship: + +| Operation | Permission stem | Relationship under `.own` | +| ---------------------- | ------------------------------ | ------------------------- | +| Read | `sessions.read` | Creator or participant | +| Collaborate | `sessions.collaborate` | Creator or participant | +| Lifecycle | `sessions.lifecycle` | Creator or participant | +| Participant management | `sessions.participants.manage` | Creator | +| Sandbox access | `sessions.sandbox_access` | Creator or participant | +| Delete | `sessions.delete` | Creator | + +The term `own` therefore has two meanings in current policy. For four operations it means any access +relationship; for deletion and participant management it means creator. + +### `requireSession` + +`requireSession(operation, sessionIdParam)` creates an active-user route policy with one session +requirement. At request admission, the router: + +1. Loads the effective authorization for the human user or represented service actor. +2. Rejects suspended users and missing role assignments. +3. Resolves the operation's `any` or `own` permission. +4. Applies the signed service's capability ceiling. +5. Forces signed service actors to `own` scope. +6. Queries `session_access` only when the resulting scope is `own`. + +Relationship failures return `session_access_required` or `creator_required` with HTTP 403. +Unexpected authorization storage failures return `authorization_unavailable` with HTTP 503. + +For sandbox-fallback routes, `requireSession` describes the user/service path. A verified sandbox +principal does not have a workspace user authorization and bypasses these RBAC requirements. Its +authority comes from the sandbox token being bound to the route's session ID. + +### D1 `session_access` + +Migration 0071 defines one canonical relationship per session and workspace user: + +```sql +CREATE TABLE session_access ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + relation TEXT NOT NULL CHECK (relation IN ('creator', 'participant')), + PRIMARY KEY (session_id, user_id) +); +``` + +The table contains no activity state, timestamps, invitation source, participant identifier, or +WebSocket state. + +Creator rows are inserted with the D1 session index. Migration 0071 backfills canonical historical +creators. Participant rows are inserted after: + +- successful public WebSocket-token issuance; +- successful public participant addition. + +Participant activation uses `ON CONFLICT DO NOTHING`, so an existing creator row is never downgraded +to participant. + +There is no production participant-removal route or D1 deactivation helper. Relationship deletion +currently occurs through session/user cascade, user merge, test setup, or direct database activity. + +### Session Durable Object participants + +The Session Durable Object has a separate `participants` table containing: + +- a session-local participant ID; +- a provider/session-local `user_id`; +- an optional canonical D1 `canonical_user_id`; +- SCM identity and credentials; +- `owner` or `member` role; +- WebSocket token hash and issuance time; +- join time. + +Session initialization creates an owner participant. WebSocket-token issuance creates or enriches a +member participant. API prompt enqueue also creates a missing participant. + +The DO `owner` or `member` value is not read by `requireSession`. Canonical creator authority comes +from D1 `session_access.relation = 'creator'`. The DO role is returned in participant responses and +persists as session-local state. + +Title, archive, and unarchive differ from other lifecycle routes: after router authorization, their +DO handlers also require the acting identity to exist in the local participants table. Stop, pull +request refresh, diff retry, and child cancellation do not share that second participant-existence +check. + +### Contribution paths + +HTTP prompt admission uses `requireSession("collaborate")`. For a built-in Member this resolves to +`collaborate.any`, so no relationship is required. The DO creates a participant when the prompt +author is not already present, but this prompt path does not create a D1 `session_access` row. + +WebSocket-token issuance also uses `collaborate`. A successful token response creates both a DO +participant and a D1 participant relationship. This means the common browser join flow establishes +the relationship after open collaboration has already authorized the join. + +Once a browser WebSocket subscribes successfully, prompt, cancel, stop, history, typing, and +presence messages use the authenticated client and its authorization lease. Individual WebSocket +commands do not independently resolve read, collaborate, or lifecycle permissions. + +### WebSocket authorization + +The initial WebSocket upgrade verifies only that the session exists. The socket remains +unauthenticated until it sends a subscription token. + +Subscription verifies: + +- the token hash maps to a DO participant; +- the participant has a canonical user ID; +- the canonical user is active and assigned; +- current `sessions.collaborate` permission; +- D1 access when collaboration scope is `.own`; +- the 24-hour token lifetime. + +A successful subscription receives a five-minute authorization lease. During that lease, permission +and relationship changes are not continuously queried. Expiry closes the socket and a later +subscription evaluates current authorization again. + +For the built-in Member's `collaborate.any`, subscription does not require the D1 relationship. For +custom roles with only `collaborate.own`, removing the relationship causes a later subscription to +fail. + +### Lists and displayed capabilities + +Session list and inbox SQL use `sessionAccessPredicate()` only when read scope is `own`. For scope +`any`, the predicate is `1 = 1`. + +Because Member and Viewer use `read.any`, their ordinary lists are workspace-wide. The `Mine` filter +is separate: it filters `sessions.user_id`, which is creator attribution rather than an +authorization relationship. + +At the time of this research, lists also computed `canManageLifecycle` from the caller's lifecycle +scope and relationship. The workspace-wide authorization implementation later removed that response +field; the web client now derives lifecycle-control visibility from current-user permissions, while +lifecycle endpoints perform their own request admission. + +### Services and bots + +Signed services use the represented canonical actor's role, a hard-coded service capability ceiling, +and a forced `own` session scope. A bot actor therefore needs a D1 creator or participant +relationship even when that actor's built-in Member role contains `read.any` and `collaborate.any`. + +This produces a contribution boundary for bot actors that does not exist for browser Members. An +unrelated Slack actor is denied when prompting another actor's session with +`session_access_required`. + +No session route currently declares an actorless service grant. Several bot call sites issue +actorless session requests, including Slack attachment/media operations and Linear stop/event +operations. Central route admission rejects such requests with `service_actor_required` before +session relationship evaluation. + +### Child sessions + +User/service child creation requires `sessions.create` and collaboration on the parent. A parent +sandbox token can create a child through the sandbox capability path without user RBAC. + +The child creator is the parent session's active prompt author. Parent access does not automatically +create child access for a different parent creator. User/service child read and cancellation are +authorized against the child, while the parent sandbox path authenticates against the parent and +then checks parent-child lineage in the handler. + +## Relevant Workflows + +### Browser Member joins an unrelated session + +1. Session list is visible through `sessions.read.any`. +2. Session read is admitted without `session_access`. +3. WebSocket-token request is admitted through `sessions.collaborate.any`. +4. The DO creates or updates a participant and rotates its token. +5. The control plane inserts D1 participant access. +6. Subscription rechecks collaboration and grants a five-minute lease. +7. The participant relationship now satisfies Member lifecycle-own and sandbox-access-own. + +### HTTP prompt without WebSocket token + +1. Prompt request is admitted through `sessions.collaborate.any` for a Member. +2. The DO creates a missing participant and enqueues the prompt. +3. No D1 participant relationship is created by this path. +4. Later lifecycle-own or sandbox-access-own checks still depend on another path having created D1 + access. + +### Actor-backed bot contribution + +1. The service signature identifies the service and represented actor. +2. The actor's current workspace authorization is loaded. +3. The service ceiling is applied. +4. Session scope is forced to `own`. +5. The actor must already have creator or participant D1 access. + +### Administrator lifecycle request without joining + +1. `sessions.lifecycle.any` passes router admission without D1 access. +2. Stop, refresh, and retry can proceed without a DO participant check. +3. Title, archive, and unarchive query the DO participant table and return 403 when the identity is + absent. + +## Existing Patterns + +- Workspace permissions and session relationships are evaluated in the control-plane router. +- The D1 relationship projection uses canonical workspace user IDs. +- The Session DO participant table owns session-local attribution, SCM metadata, tokens, and + connection identity. +- Open collaboration is expressed by built-in `*.any` permissions rather than an exception inside + relationship code. +- Service actors are intentionally narrowed to `own` regardless of their human role's `any` grant. +- Sandbox principals use possession of a session-bound capability instead of workspace RBAC. +- WebSocket authorization is evaluated at subscription and represented by a bounded lease. +- Session list authorization and lifecycle capability are calculated in SQL before results are + returned. + +## Constraints and Invariants + +- One canonical user has at most one D1 relationship per session. +- Creator access is not replaced by participant activation. +- Own-scoped deletion and participant management require creator relation. +- Other own-scoped operations accept creator or participant relation. +- Any-scoped operations do not consult `session_access`. +- Actor-backed services cannot use any-scoped session access. +- A sandbox token is valid only for its bound session route. +- Successful WebSocket subscription requires a canonical user identity. +- WebSocket authorization is bounded by a five-minute lease and token use by a 24-hour lifetime. +- D1 and Session DO writes do not share a cross-store transaction. +- User merge preserves the strongest D1 relationship when creator and participant rows collide. + +## Known Gaps and Risks + +### Relationship and participant divergence + +The two stores have different writers and no reconciliation workflow: + +- API prompt creates a DO participant without D1 access. +- DO success followed by D1 activation failure leaves a DO participant without D1 access. +- D1 user merge rewrites access but does not update existing DO canonical participant identities. +- There is no participant-removal flow spanning D1, DO tokens, presence, or existing sockets. +- DO `owner/member` and D1 `creator/participant` can disagree. + +### Inconsistent lifecycle enforcement + +Title, archive, and unarchive require local DO participant existence after router authorization. +Other lifecycle endpoints do not. This makes `sessions.lifecycle.any` behavior dependent on the +specific endpoint and whether the caller previously joined the session. + +### Contribution does not uniformly establish access + +WebSocket-token contribution establishes D1 participant access; direct HTTP prompting does not. Both +can establish a DO participant. + +### Service-call mismatches + +Some bot call sites omit actors for routes whose central policy requires one. Package-local tests +mock the control plane and do not cover these calls through real central authorization. + +### Documentation drift + +The RBAC design includes mutually inconsistent statements about Member visibility. Its role matrix +describes open Member read/collaboration, while other sections describe Member lists as +creator/participant filtered. It also documents participant removal that is not implemented and +states that the DO has no local owner role even though that field remains in schema and runtime +behavior. + +### Test coverage boundaries + +Existing tests cover scoped permission resolution, relationship checks, list filtering, WebSocket +subscription, service actor isolation, creator-only deletion, and projection writes. No +comprehensive role-by-operation HTTP matrix or end-to-end test of active WebSocket authorization +changes across a lease boundary was found. + +## Open Questions + +1. Is `session_access` intended to represent durable membership, a capability projection, or only + the relationship input for `.own` permissions? +2. Is open Member contribution intended to establish membership, or is the relationship created by + WebSocket-token issuance incidental to the current browser workflow? +3. Is direct HTTP prompt participation intentionally excluded from D1 participant activation? +4. Are the DO participant checks on title, archive, and unarchive intentional authorization or + residual pre-RBAC behavior? +5. Does actor-backed service isolation intentionally differ from open browser Member collaboration? +6. Are DO `owner/member` roles still part of supported session semantics, or only retained state for + compatibility and presentation? +7. Was participant removal deliberately excluded from the current product surface? +8. Is parent-to-child access intentionally independent when the active prompt author differs from + the parent creator? +9. Are the RBAC design documents historical artifacts, living documentation, or a mixture of both? + +## Evidence + +- `packages/shared/src/rbac.ts`: built-in role permission sets and any-before-own scope resolution. +- `packages/control-plane/src/authorization/session-authorization-policy.ts`: + operation-to-permission and operation-to-relationship mapping. +- `packages/control-plane/src/routes/shared.ts`: `requireSession` route metadata construction. +- `packages/control-plane/src/router.ts`: active-user, service-ceiling, scoped-permission, and + relationship enforcement. +- `packages/control-plane/src/db/session-access.ts`: list predicate, exact relationship check, and + participant activation. +- `terraform/d1/migrations/0071_rbac_foundation.sql`: relationship schema, index, and creator + backfill. +- `packages/control-plane/src/db/session-index.ts`: creator insertion, own-scoped listing, and + lifecycle capability projection. +- `packages/control-plane/src/db/session-inbox-store.ts`: inbox visibility and lifecycle capability. +- `packages/control-plane/src/routes/session-ws-token.ts`: public token issuance and D1 participant + activation. +- `packages/control-plane/src/routes/session-prompt.ts`: collaboration admission and + principal-derived prompt identity. +- `packages/control-plane/src/session/message-queue.ts`: prompt-created DO participants. +- `packages/control-plane/src/session/schema.ts`: DO participant schema and owner/member role. +- `packages/control-plane/src/session/connection-authenticator.ts`: WebSocket token, canonical user, + authorization, and token-age checks. +- `packages/control-plane/src/session/websocket-manager.ts`: lease persistence, lookup, and expiry. +- `packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts`: residual DO + participant checks for title/archive/unarchive. +- `packages/control-plane/src/authorization/service-permissions.ts`: bot service capability + ceilings. +- `packages/control-plane/test/integration/rbac-routes.test.ts`: open Member lists and creator-only + deletion. +- `packages/control-plane/test/integration/websocket-client.test.ts`: any/own collaboration, + relationship loss, suspension, and assignment failure behavior. +- `packages/control-plane/test/integration/service-auth.test.ts`: actor-backed service relationship + isolation. +- `packages/control-plane/test/integration/d1-session-index.test.ts`: creator projection, missing + projection, and lifecycle capability behavior. +- `packages/control-plane/test/integration/user-merge.test.ts`: relationship collision precedence. +- `public/docs/internal/2026-08-28-rbac-design.md`: stated RBAC model and observed documentation + contradictions. +- Git commit `69d32c6`: changed Member read and collaboration from own to any while retaining the + relationship projection for narrower operations. diff --git a/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md b/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md new file mode 100644 index 000000000..5934a94d1 --- /dev/null +++ b/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md @@ -0,0 +1,193 @@ +# Design: Workspace-Wide Session Authorization + +**Date:** 2026-08-30 + +**Status:** Accepted + +**Research:** [2026-08-30-session-access-research.md](./2026-08-30-session-access-research.md) + +## Summary + +Open-Inspect sessions are workspace-wide resources. An active user may perform an operation on every +session when their workspace role grants that operation. Session creator and participant +relationships do not grant, narrow, or revoke authorization. + +Session authorization uses unscoped operation permissions. Actor-backed bot requests intersect the +represented user's current role with the bot service's fixed capability ceiling, without applying a +session relationship check. + +Creator attribution, participant identity, sandbox capability binding, and WebSocket authorization +remain supported concerns, but none is a session access-control list. + +## Context + +Before workspace RBAC, authenticated users could operate across sessions without a creator or +participant authorization boundary. The RBAC foundation introduced `.own` and `.any` session +permission pairs and a D1 `session_access` projection. Built-in Members still received +workspace-wide read and collaboration, while lifecycle, sandbox access, deletion, participant +management, and bot requests became relationship-dependent. + +That partial relationship model does not match the product's multiplayer behavior. It also creates +two inconsistent participant stores: D1 relationships used for authorization and Session Durable +Object participants used for message identity, presence, SCM metadata, and WebSocket tokens. +Different contribution paths update those stores differently. + +## Decisions + +### Workspace-wide operations + +Session permissions are operation permissions without resource scope: + +- `sessions.read` +- `sessions.collaborate` +- `sessions.create` +- `sessions.lifecycle` +- `sessions.sandbox_access` +- `sessions.delete` + +A granted session operation applies to every session in the workspace. No route or WebSocket +authorization check consults creator or participant relationships. + +Deletion is workspace-scoped. Creator-only deletion is explicitly deferred and is not part of this +RBAC change. + +### Built-in roles + +Built-in roles distinguish which operations a user may perform, not which sessions they may target: + +| Role | Session behavior | +| ------------- | ----------------------------------------------------------------------------------- | +| Owner | Every session operation across the workspace. | +| Administrator | Every session operation across the workspace. | +| Member | Create, read, collaborate, manage lifecycle, access sandboxes, and delete sessions. | +| Viewer | Read every session; no create, collaborate, lifecycle, sandbox, or delete access. | + +Custom roles may contain any registered session operation permission. Custom roles cannot express +private, invitation-only, creator-only, or participant-only session access. + +### Actor-backed services + +A bot service acting for a human uses the intersection of two operation sets: + +```text +effective operations = actor role permissions intersect service capability ceiling +``` + +The represented actor must resolve to an active canonical workspace user. The service cannot exceed +the actor's role or its own ceiling. If both grant `sessions.collaborate`, the actor may collaborate +on any session, including a session created by another user. This preserves multiplayer Slack, +GitHub, and Linear workflows. + +Actorless service calls remain limited to narrow route-specific grants. + +### Creator attribution + +`sessions.user_id` records the canonical user responsible for creating a session. It supports +display, filtering, auditing, credential selection, automation lineage, and other attribution needs. +It is not an authorization relationship. + +The `Mine` session-list filter continues to select sessions by creator attribution. It is a user +filter, not an access boundary. + +### Participant identity + +Session Durable Object participants identify message authors and connected clients. They may retain: + +- provider identity and canonical user linkage; +- display and SCM metadata; +- message attribution; +- presence identity; +- WebSocket token ownership. + +Participant existence and the persisted `owner` or `member` value do not authorize session +operations. Joining or contributing to a session does not create a separate authorization grant. + +Participant-management APIs that exist only to maintain access-control relationships are removed. +Runtime participant creation required for attribution remains internal to contribution and +WebSocket-token flows. + +### WebSockets + +WebSocket token issuance and subscription require an active canonical user with +`sessions.collaborate`. Tokens remain bound to their session and participant identity. Subscription +authorization is rechecked through bounded leases so suspension or role changes affect live access. + +The authorization recheck evaluates active workspace membership and `sessions.collaborate`; it does +not evaluate creator or participant access records. + +### Sandbox capabilities + +Human or actor-backed requests for sandbox credentials require `sessions.sandbox_access`, which +applies workspace-wide. Sandbox-originated control-plane requests continue to authenticate with a +session-bound sandbox capability and remain restricted to that session. + +Human workspace authorization and sandbox capability binding are separate security boundaries. + +### Lifecycle and state checks + +Lifecycle routes require `sessions.lifecycle` for every session. Session state-machine checks, +queued-work checks, and sandbox runtime constraints continue to apply. + +Durable Object participant existence is not a lifecycle authorization condition. Rename, archive, +and unarchive follow the same workspace permission policy as stop, retry, and refresh. + +### Service and UI metadata + +Session lists are not filtered by authorization relationships. Query filters such as creator and +status remain supported. + +The web client derives lifecycle-control visibility from the current user's workspace +`sessions.lifecycle` permission. Session list and inbox responses contain session data, not +authorization presentation metadata; lifecycle endpoints remain authoritative. + +## Removed Model + +The RBAC foundation does not include: + +- a D1 `session_access` table; +- creator or participant authorization projections; +- `.own` and `.any` session permission pairs; +- relationship-filtered session or inbox queries; +- relationship activation during WebSocket token issuance; +- relationship-aware user merge behavior; +- creator-only deletion or participant management; +- bot-specific narrowing to sessions associated with the represented actor. + +Because this schema and permission model were introduced on the unshipped RBAC branch, they are +removed directly from the branch migration and permission registry rather than retained as a +compatibility layer. + +## Deferred Features + +Private, invitation-only, creator-restricted, or participant-restricted sessions require a separate +product design. Such a design must address visibility, invitations, removal, revocation, historical +participants, bot behavior, parent-child sessions, cross-store consistency, migration, and UI. + +No relationship schema or permission identifiers are retained speculatively for that future work. + +## Invariants + +- A workspace permission has the same meaning for browser users and represented bot actors. +- A service may narrow an actor's operations but may not expand them. +- Session creator and participant data are attribution and runtime identity, not authorization. +- Every user with `sessions.read` can read and list every session. +- Every user with `sessions.collaborate` can contribute to every session. +- Every user with `sessions.lifecycle` can invoke lifecycle operations on every session. +- Every user with `sessions.sandbox_access` can request sandbox access for every session. +- Every user with `sessions.delete` can delete every session. +- Sandbox credentials remain bound to one session regardless of human workspace permissions. +- Suspension and role changes apply to new HTTP requests and bounded-lifetime WebSocket leases. + +## Verification + +The implementation must cover: + +- a role-by-operation HTTP authorization matrix; +- cross-user browser collaboration; +- cross-user actor-backed bot listing and collaboration; +- service ceiling denial when the actor role permits an operation the service does not; +- Viewer read access and mutation denial; +- workspace-wide lifecycle, sandbox, and deletion behavior for permitted roles; +- WebSocket subscription reauthorization after role or suspension changes; +- session-bound sandbox authentication; +- lifecycle consistency across rename, archive, unarchive, stop, retry, and refresh. From a1a5858361a3f74cda858e56d6335528bd4f1148 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Mon, 31 Aug 2026 01:39:07 -0700 Subject: [PATCH 11/11] fix: address permission-aware UI review feedback --- .../control-plane/src/router.policy.test.ts | 6 +- packages/control-plane/src/router.ts | 14 +- .../src/routes/session-runtime-proxy.test.ts | 74 +- .../src/routes/session-runtime-proxy.ts | 44 +- .../src/routes/session-ws-token.test.ts | 2 +- .../src/routes/session-ws-token.ts | 7 +- .../src/sandbox/lifecycle/manager.test.ts | 76 +- .../src/sandbox/lifecycle/manager.ts | 4 +- .../src/session/client-command-facade.ts | 8 + .../control-plane/src/session/components.ts | 19 +- .../src/session/connection-authenticator.ts | 56 +- .../src/session/message-router.ts | 27 + .../src/session/sandbox-access-reader.ts | 14 +- .../control-plane/src/session/server.test.ts | 25 + .../test/integration/session-snapshot.test.ts | 2 + .../test/integration/websocket-client.test.ts | 32 +- .../integration/websocket-sandbox.test.ts | 2 + packages/shared/src/rbac.test.ts | 6 + packages/shared/src/rbac.ts | 18 +- .../shared/src/types/server-messages.test.ts | 31 +- packages/shared/src/types/server-messages.ts | 11 + .../app/(app)/(sidebar)/session/[id]/page.tsx | 53 +- .../web/src/components/action-bar.test.tsx | 27 +- packages/web/src/components/action-bar.tsx | 6 +- .../src/components/diff-retry-notice.test.tsx | 48 +- .../web/src/components/diff-retry-notice.tsx | 7 +- .../src/components/mobile-session-actions.tsx | 7 +- .../components/queued-prompt-stack.test.tsx | 15 +- .../src/components/queued-prompt-stack.tsx | 7 +- .../web/src/components/session-actions.ts | 3 +- .../components/session-changes-panel.test.tsx | 14 + .../src/components/session-changes-panel.tsx | 7 +- .../components/session-details-overlay.tsx | 8 +- .../src/components/session-header.test.tsx | 32 +- .../web/src/components/session-header.tsx | 23 +- .../session-prompt-composer.test.tsx | 21 + .../components/session-prompt-composer.tsx | 7 +- .../components/session-right-sidebar.test.tsx | 11 +- .../src/components/session-right-sidebar.tsx | 29 +- .../web/src/components/session-sidebar.tsx | 1 - .../sidebar/metadata-section.test.tsx | 12 +- .../components/sidebar/metadata-section.tsx | 4 +- packages/web/src/hooks/use-sandbox-access.ts | 8 +- .../web/src/hooks/use-session-socket.test.tsx | 122 ++- packages/web/src/hooks/use-session-socket.ts | 8 +- .../src/hooks/use-session-transport.test.tsx | 24 + .../web/src/hooks/use-session-transport.ts | 24 +- .../web/src/lib/automation-authorization.ts | 9 +- packages/web/src/lib/session-capabilities.ts | 20 + .../docs/internal/2026-08-28-rbac-design.md | 815 ------------------ .../docs/internal/2026-08-28-rbac-research.md | 386 --------- .../2026-08-30-session-access-research.md | 407 --------- ...space-wide-session-authorization-design.md | 193 ----- 53 files changed, 777 insertions(+), 2059 deletions(-) create mode 100644 packages/web/src/lib/session-capabilities.ts delete mode 100644 public/docs/internal/2026-08-28-rbac-design.md delete mode 100644 public/docs/internal/2026-08-28-rbac-research.md delete mode 100644 public/docs/internal/2026-08-30-session-access-research.md delete mode 100644 public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md diff --git a/packages/control-plane/src/router.policy.test.ts b/packages/control-plane/src/router.policy.test.ts index 2a0ad18f2..d5e1b4d91 100644 --- a/packages/control-plane/src/router.policy.test.ts +++ b/packages/control-plane/src/router.policy.test.ts @@ -154,11 +154,7 @@ describe("route policy table", () => { }); expect(routeFor("POST", "/sessions/session-1/ws-token")?.authorization).toMatchObject({ kind: "active-user", - allOf: [ - { kind: "permission", permission: "sessions.read" }, - { kind: "permission", permission: "sessions.collaborate" }, - { kind: "permission", permission: "sessions.lifecycle" }, - ], + allOf: [{ kind: "permission", permission: "sessions.read" }], }); expect(routeFor("POST", "/sessions/session-1/stop")?.authorization).toMatchObject({ service: { kind: "actor", actorlessGrants: [{ service: "linear-bot" }] }, diff --git a/packages/control-plane/src/router.ts b/packages/control-plane/src/router.ts index e13e0a4ce..e06a57687 100644 --- a/packages/control-plane/src/router.ts +++ b/packages/control-plane/src/router.ts @@ -19,7 +19,11 @@ 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 { + SCOPED_PERMISSION_PAIRS, + hasScopedPermission, + resolveScopedPermission, +} from "@open-inspect/shared/rbac"; import { createLogger } from "./logger"; import type { BackgroundTasks } from "./platform-ports"; import { @@ -478,11 +482,13 @@ async function enforceAutomationRequirement( const automation = await store.resolveCanonicalOwner(storedAutomation); 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) + !hasScopedPermission( + permissionStem, + authorization.permissions, + automation.user_id === ctx.principal.userId + ) ) { return json( { error: "Forbidden", code: "permission_required", permission: ownPermission }, 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 62688ce26..9ecb62ae2 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.test.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it, vi } from "vitest"; import { SessionInternalPaths } from "../session/contracts"; +import type { PermissionId } from "@open-inspect/shared/rbac"; import type { RequestContext } from "./shared"; import type { SqlDatabase } from "../db/sql-database"; import { sessionRuntimeProxyRoutes } from "./session-runtime-proxy"; import type { Env } from "../types"; import { TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support"; -function createCtx(db: SqlDatabase = {} as SqlDatabase): RequestContext { +function createCtx( + db: SqlDatabase = {} as SqlDatabase, + permissions: PermissionId[] = ["sessions.read"] +): RequestContext { return { trace_id: "trace-1", request_id: "req-1", @@ -16,6 +20,12 @@ function createCtx(db: SqlDatabase = {} as SqlDatabase): RequestContext { kind: "user", userId: "user-1", }, + authorization: { + userId: "user-1", + suspendedAt: null, + role: { id: "role-1", key: "viewer", name: "Viewer" }, + permissions, + }, metrics: { d1Queries: [], spans: {}, @@ -44,15 +54,13 @@ function getHandler(method: string, path: string) { } describe("session runtime proxy routes", () => { - it.each([ - ["snapshot", "/sessions/session-1", SessionInternalPaths.snapshot], - ["sandbox access", "/sessions/session-1/sandbox-access", SessionInternalPaths.sandboxAccess], - ])("forwards %s for users", async (_name, path, internalPath) => { + it("forwards sandbox access for users", async () => { const requests: Request[] = []; const fetch = vi.fn(async (request: Request) => { requests.push(request); return Response.json({ sessionId: "session-1" }); }); + const path = "/sessions/session-1/sandbox-access"; const { handler, match } = getHandler("GET", path); const response = await handler( @@ -63,10 +71,64 @@ describe("session runtime proxy routes", () => { ); expect(response.status).toBe(200); - expect(new URL(requests[0].url).pathname).toBe(internalPath); + expect(new URL(requests[0].url).pathname).toBe(SessionInternalPaths.sandboxAccess); expect(fetch).toHaveBeenCalledOnce(); }); + it.each([ + { permissions: ["sessions.read"] as PermissionId[], exposed: false }, + { + permissions: ["sessions.read", "sessions.sandbox_access"] as PermissionId[], + exposed: true, + }, + ])("scopes snapshot sandbox locations to sandbox access ($exposed)", async (input) => { + const fetch = vi.fn(async () => + Response.json({ + session: { + id: "session-1", + title: "Session", + repoOwner: "acme", + repoName: "web", + baseBranch: "main", + branchName: "feature", + status: "active", + sandboxStatus: "ready", + messageCount: 0, + createdAt: 1, + codeServerUrl: "https://code.example", + vncUrl: "https://vnc.example", + ttydUrl: "https://terminal.example", + tunnelUrls: { "3000": "https://app.example" }, + sandboxDashboardUrl: "https://provider.example", + }, + artifacts: [], + promptQueue: [], + timeline: { events: [], hasMore: false, cursor: null }, + }) + ); + const path = "/sessions/session-1"; + const { handler, match } = getHandler("GET", path); + + const response = await handler( + new Request(`https://test.local${path}`), + createEnv(fetch), + match, + createCtx({} as SqlDatabase, input.permissions) + ); + const snapshot = (await response.json()) as { session: Record }; + + expect(response.status).toBe(200); + if (input.exposed) { + expect(snapshot.session).toHaveProperty("codeServerUrl", "https://code.example"); + } else { + expect(snapshot.session).not.toHaveProperty("codeServerUrl"); + expect(snapshot.session).not.toHaveProperty("vncUrl"); + expect(snapshot.session).not.toHaveProperty("ttydUrl"); + expect(snapshot.session).not.toHaveProperty("tunnelUrls"); + expect(snapshot.session).not.toHaveProperty("sandboxDashboardUrl"); + } + }); + it("forwards event query strings through the session runtime dependency", 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 c6838a29a..7eb9244a7 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.ts @@ -3,6 +3,10 @@ import type { SessionParticipantProfilesResponse, SessionParticipantProfile, } from "@open-inspect/shared/types/sessions"; +import { + redactSessionSnapshotSandboxAccess, + sessionSnapshotSchema, +} from "@open-inspect/shared/types/server-messages"; import { z } from "zod"; import { UserStore } from "../db/user-store"; import { SessionIndexStore } from "../db/session-index"; @@ -181,6 +185,29 @@ async function handleParticipantProfiles( return Response.json({ profiles } satisfies SessionParticipantProfilesResponse); } +async function handleSessionSnapshot( + _request: Request, + _env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise { + const sessionId = getSessionId(match); + if (sessionId instanceof Response) return sessionId; + + const response = await ctx.sessionRuntime.fetch(sessionId, SessionInternalPaths.snapshot); + if (response.status === 404) return error("Session not found", 404); + if (!response.ok) return response; + + const parsed = sessionSnapshotSchema.safeParse(await response.json().catch(() => null)); + if (!parsed.success) return error("Invalid session snapshot", 502); + const snapshot = ctx.authorization?.permissions.includes("sessions.sandbox_access") + ? parsed.data + : redactSessionSnapshotSandboxAccess(parsed.data); + const headers = new Headers(response.headers); + headers.delete("Content-Length"); + return Response.json(snapshot, { headers }); +} + async function handleCreatePR( request: Request, _env: Env, @@ -297,14 +324,15 @@ export const sessionRuntimeProxyRoutes: Route[] = [ 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", - }), + defineRoute( + SCM_AGNOSTIC_HUMAN_USER_ROUTE, + sessionRoute({ + method: "GET", + pattern: parsePattern("/sessions/:id"), + authorization: requirePermission("sessions.read"), + handler: handleSessionSnapshot, + }) + ), simpleProxyRoute({ policy: GITHUB_USER_OR_SERVICE_ROUTE, method: "POST", 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 608d5f471..ec925177d 100644 --- a/packages/control-plane/src/routes/session-ws-token.test.ts +++ b/packages/control-plane/src/routes/session-ws-token.test.ts @@ -37,7 +37,7 @@ function createContext(db: SqlDatabase = accessDatabase().db): RequestContext { userId: "user-1", suspendedAt: null, role: { id: "role-1", key: "member", name: "Member" }, - permissions: ["sessions.collaborate"], + permissions: ["sessions.read"], }, metrics: { d1Queries: [], diff --git a/packages/control-plane/src/routes/session-ws-token.ts b/packages/control-plane/src/routes/session-ws-token.ts index 096c2a775..8e959cde6 100644 --- a/packages/control-plane/src/routes/session-ws-token.ts +++ b/packages/control-plane/src/routes/session-ws-token.ts @@ -1,5 +1,5 @@ import { applyIdentityEnforcement } from "../auth/identity-enforcement"; -import { SESSION_WEBSOCKET_PERMISSIONS } from "@open-inspect/shared/rbac"; +import { SESSION_WEBSOCKET_CONNECT_PERMISSION } from "@open-inspect/shared/rbac"; import { SessionInternalPaths, sessionScmDisplayFieldsSchema } from "../session/contracts"; import type { Env } from "../types"; import { @@ -8,8 +8,7 @@ import { GITHUB_USER_OR_SERVICE_ROUTE, parseJsonBody, parsePattern, - permissionRequirement, - requireAll, + requirePermission, type Route, } from "./shared"; import { sessionRoute, type SessionRouteContext } from "./session-route"; @@ -60,7 +59,7 @@ export const sessionWsTokenRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE sessionRoute({ method: "POST", pattern: parsePattern("/sessions/:id/ws-token"), - authorization: requireAll(...SESSION_WEBSOCKET_PERMISSIONS.map(permissionRequirement)), + authorization: requirePermission(SESSION_WEBSOCKET_CONNECT_PERMISSION), handler: handleSessionWsToken, }), ]); diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts index e4cb2453f..b5b2c5752 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts @@ -477,16 +477,14 @@ async function expectEarlyBridgeStartup(kind: ProviderStartupKind): Promise (message as { type: string }).type === "sandbox_access_changed" ) - ).toHaveLength(1); - expect(accessAtBroadcast).toEqual([ - { - code_server_url: access.codeServerUrl, - code_server_password: access.codeServerPassword, - vnc_url: access.vncAccess.url, - vnc_password: access.vncAccess.password, - tunnel_urls: JSON.stringify(access.tunnelUrls), - }, - ]); + ).not.toHaveLength(0); + expect(accessAtBroadcast.at(-1)).toEqual({ + code_server_url: access.codeServerUrl, + code_server_password: access.codeServerPassword, + vnc_url: access.vncAccess.url, + vnc_password: access.vncAccess.password, + tunnel_urls: JSON.stringify(access.tunnelUrls), + }); } // ==================== Tests ==================== @@ -844,13 +842,10 @@ describe("SandboxLifecycleManager", () => { expect(storage.calls).toContain("updateSandboxModalObjectId:provider-obj-123"); expect( - broadcaster.messages.filter((m) => (m as { type: string }).type === "sandbox_dashboard_url") - ).toEqual([ - { - type: "sandbox_dashboard_url", - url: "https://provider.example/provider-obj-123", - }, - ]); + broadcaster.messages.filter( + (m) => (m as { type: string }).type === "sandbox_access_changed" + ) + ).toContainEqual({ type: "sandbox_access_changed" }); }); it("does not broadcast sandbox_dashboard_url when no builder is configured", async () => { @@ -873,7 +868,7 @@ describe("SandboxLifecycleManager", () => { expect(storage.calls).toContain("updateSandboxModalObjectId:provider-obj-123"); expect( - broadcaster.messages.some((m) => (m as { type: string }).type === "sandbox_dashboard_url") + broadcaster.messages.some((m) => (m as { type: string }).type === "sandbox_access_changed") ).toBe(false); }); @@ -1360,13 +1355,10 @@ describe("SandboxLifecycleManager", () => { expect(storage.calls).toContain("updateSandboxModalObjectId:restored-obj-456"); expect( - broadcaster.messages.filter((m) => (m as { type: string }).type === "sandbox_dashboard_url") - ).toEqual([ - { - type: "sandbox_dashboard_url", - url: "https://provider.example/restored-obj-456", - }, - ]); + broadcaster.messages.filter( + (m) => (m as { type: string }).type === "sandbox_access_changed" + ) + ).toContainEqual({ type: "sandbox_access_changed" }); }); it("broadcasts sandbox_dashboard_url after resume when provider object id changes", async () => { @@ -1405,13 +1397,10 @@ describe("SandboxLifecycleManager", () => { expect(provider.resumeSandbox).toHaveBeenCalled(); expect(storage.calls).toContain("updateSandboxModalObjectId:new-provider-obj"); expect( - broadcaster.messages.filter((m) => (m as { type: string }).type === "sandbox_dashboard_url") - ).toEqual([ - { - type: "sandbox_dashboard_url", - url: "https://provider.example/new-provider-obj", - }, - ]); + broadcaster.messages.filter( + (m) => (m as { type: string }).type === "sandbox_access_changed" + ) + ).toContainEqual({ type: "sandbox_access_changed" }); }); it("broadcasts sandbox_dashboard_url after resume when provider object id is unchanged", async () => { @@ -1450,13 +1439,10 @@ describe("SandboxLifecycleManager", () => { expect(provider.resumeSandbox).toHaveBeenCalled(); expect(storage.calls).not.toContain("updateSandboxModalObjectId:same-provider-obj"); expect( - broadcaster.messages.filter((m) => (m as { type: string }).type === "sandbox_dashboard_url") - ).toEqual([ - { - type: "sandbox_dashboard_url", - url: "https://provider.example/same-provider-obj", - }, - ]); + broadcaster.messages.filter( + (m) => (m as { type: string }).type === "sandbox_access_changed" + ) + ).toContainEqual({ type: "sandbox_access_changed" }); }); it("does not carry a predecessor's runtime version onto a replacement's snapshot", async () => { @@ -3540,11 +3526,7 @@ describe("SandboxLifecycleManager", () => { expect(storage.calls).toContain("updateSandboxTunnelUrls"); expect( - broadcaster.messages.some( - (m) => - (m as { type: string }).type === "tunnel_urls" && - (m as { urls: Record }).urls["3000"] === "https://tunnel.example.com" - ) + broadcaster.messages.some((m) => (m as { type: string }).type === "sandbox_access_changed") ).toBe(true); }); @@ -3644,11 +3626,7 @@ describe("SandboxLifecycleManager", () => { expect(storage.calls).toContain("updateSandboxTunnelUrls"); expect( - broadcaster.messages.some( - (m) => - (m as { type: string }).type === "tunnel_urls" && - (m as { urls: Record }).urls["3000"] === "https://tunnel.example.com" - ) + broadcaster.messages.some((m) => (m as { type: string }).type === "sandbox_access_changed") ).toBe(true); }); }); diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.ts b/packages/control-plane/src/sandbox/lifecycle/manager.ts index b549de9e2..a2aabdce6 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.ts @@ -1597,7 +1597,7 @@ export class SandboxLifecycleManager implements SandboxLifecycle { this.log.debug("Broadcasting sandbox dashboard URL", { provider_object_id: providerObjectId, }); - this.broadcaster.broadcast({ type: "sandbox_dashboard_url", url }); + this.broadcaster.broadcast({ type: "sandbox_access_changed" }); } } @@ -1640,7 +1640,7 @@ export class SandboxLifecycleManager implements SandboxLifecycle { if (!urls || Object.keys(urls).length === 0) return; this.log.info("Storing and broadcasting tunnel URLs", { ports: Object.keys(urls) }); await this.storage.updateSandboxTunnelUrls(urls); - this.broadcaster.broadcast({ type: "tunnel_urls", urls }); + this.broadcaster.broadcast({ type: "sandbox_access_changed" }); } /** Mint and persist terminal access. */ diff --git a/packages/control-plane/src/session/client-command-facade.ts b/packages/control-plane/src/session/client-command-facade.ts index f7b7f4d99..f03aa879b 100644 --- a/packages/control-plane/src/session/client-command-facade.ts +++ b/packages/control-plane/src/session/client-command-facade.ts @@ -20,6 +20,7 @@ import type { SessionEventStream, SessionHistoryPage } from "./event-stream"; import type { SessionConnectionAuthenticator } from "./connection-authenticator"; import type { SessionMessageQueue } from "./message-queue"; import type { PresenceService } from "./presence-service"; +import type { PermissionId } from "@open-inspect/shared/rbac"; export class SessionClientCommandFacade implements SessionClientCommands { constructor( @@ -59,4 +60,11 @@ export class SessionClientCommandFacade implements SessionClientCommands { + return this.authenticator.authorizeClientCommand(client.userId, permission); + } } diff --git a/packages/control-plane/src/session/components.ts b/packages/control-plane/src/session/components.ts index f1f80b69e..85858afde 100644 --- a/packages/control-plane/src/session/components.ts +++ b/packages/control-plane/src/session/components.ts @@ -22,7 +22,6 @@ */ import { resolveAppName } from "@open-inspect/shared/app-name"; -import { SESSION_WEBSOCKET_PERMISSIONS } from "@open-inspect/shared/rbac"; import { DEFAULT_MODEL } from "@open-inspect/shared/models"; import { generateId, hashToken, encryptToken } from "../auth/crypto"; import { resolveSandboxBackendName } from "../sandbox/provider-name"; @@ -671,6 +670,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi sessionCoreRepository, sandboxRepository, repoSecretsEncryptionKey, + sandboxDashboardSettings, log, }); @@ -687,23 +687,20 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi snapshotReader, schedulePullRequestRefresh, scmProviderName, - verifyAuthorization: async (userId) => { - if (!db) return "unavailable"; + resolveAuthorization: async (userId) => { + if (!db) return { kind: "unavailable" }; try { const authorization = await new AuthorizationService(db).getEffectiveAuthorization(userId); - return authorization.suspendedAt === null && - SESSION_WEBSOCKET_PERMISSIONS.every((permission) => - authorization.permissions.includes(permission) - ) - ? "valid" - : "rejected"; + return authorization.suspendedAt === null + ? { kind: "valid", authorization } + : { kind: "rejected" }; } catch (error) { - if (error instanceof AuthorizationError) return "rejected"; + if (error instanceof AuthorizationError) return { kind: "rejected" }; log.error("WebSocket authorization verification failed", { user_id: userId, error: error instanceof Error ? error : String(error), }); - return "unavailable"; + return { kind: "unavailable" }; } }, log, diff --git a/packages/control-plane/src/session/connection-authenticator.ts b/packages/control-plane/src/session/connection-authenticator.ts index 016c1c29c..80640ee0b 100644 --- a/packages/control-plane/src/session/connection-authenticator.ts +++ b/packages/control-plane/src/session/connection-authenticator.ts @@ -1,5 +1,9 @@ import { isSessionPromptable } from "@open-inspect/shared/types/session-activity"; -import type { ServerMessage } from "@open-inspect/shared/types/server-messages"; +import type { EffectiveAuthorization, PermissionId } from "@open-inspect/shared/rbac"; +import { + redactSessionSnapshotSandboxAccess, + type ServerMessage, +} from "@open-inspect/shared/types/server-messages"; import { WS_AUTHORIZATION_REVOKED_REASON, WS_CLOSE_AUTHORIZATION_REVOKED, @@ -45,12 +49,18 @@ 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">; + /** Resolve a user's current authorization at the start of a subscription or command. */ + resolveAuthorization: (userId: string) => Promise; /** The session-scoped logger; upgrade/subscribe paths also receive request-scoped children. */ log: Logger; } +type AuthorizationResolution = + | { kind: "valid"; authorization: EffectiveAuthorization } + | { kind: "rejected" | "unavailable" }; + +export type ClientCommandAuthorization = "allowed" | "denied" | "unavailable"; + /** * Admits connections to the session: sandbox WebSocket upgrades (token + * lifecycle-state guards, re-checked after the non-storage token-hash await), @@ -270,18 +280,23 @@ export class SessionConnectionAuthenticator { // Authorization is intentionally sampled once at the start of this // subscription request. A concurrent role change takes effect when this // bounded lease expires, not midway through an in-flight request. - const authorization = await this.deps.verifyAuthorization(participant.canonical_user_id); - if (authorization !== "valid") { + const authorization = await this.deps.resolveAuthorization(participant.canonical_user_id); + if ( + authorization.kind !== "valid" || + !authorization.authorization.permissions.includes("sessions.read") + ) { log.warn("ws.connect", { event: "ws.connect", ws_type: "client", outcome: "auth_failed", reject_reason: - authorization === "unavailable" ? "authorization_unavailable" : "authorization_denied", + authorization.kind === "unavailable" + ? "authorization_unavailable" + : "authorization_denied", participant_id: participant.id, user_id: participant.canonical_user_id, }); - if (authorization === "unavailable") { + if (authorization.kind === "unavailable") { wsManager.close(ws, WS_CLOSE_INTERNAL_ERROR, "Authorization temporarily unavailable"); } else { wsManager.close(ws, WS_CLOSE_AUTHORIZATION_REVOKED, WS_AUTHORIZATION_REVOKED_REASON); @@ -322,7 +337,12 @@ export class SessionConnectionAuthenticator { try { const activated = await wsManager.activateClient(ws, clientInfo, () => - this.completeClientSubscription(ws, clientInfo, enrichment) + this.completeClientSubscription( + ws, + clientInfo, + enrichment, + authorization.authorization.permissions.includes("sessions.sandbox_access") + ) ); if (!activated) { wsManager.close(ws, 4009, "Session synchronization failed"); @@ -361,16 +381,20 @@ export class SessionConnectionAuthenticator { private completeClientSubscription( ws: WebSocket, client: ClientInfo, - enrichment: Parameters[0] + enrichment: Parameters[0], + canAccessSandbox: boolean ): boolean { const { wsManager, snapshotReader } = this.deps; const snapshot = snapshotReader.readSessionSnapshot(enrichment); if (!snapshot) return false; + const authorizedSnapshot = canAccessSandbox + ? snapshot + : redactSessionSnapshotSandboxAccess(snapshot); if ( !wsManager.send(ws, { type: "subscribed", - ...snapshot, + ...authorizedSnapshot, participantId: client.participantId, participant: { participantId: client.participantId, @@ -386,6 +410,18 @@ export class SessionConnectionAuthenticator { return true; } + /** Samples one permission before dispatching a WebSocket command. */ + async authorizeClientCommand( + userId: string, + permission: PermissionId + ): Promise { + const resolution = await this.deps.resolveAuthorization(userId); + if (resolution.kind === "unavailable") return "unavailable"; + if (resolution.kind === "rejected") return "denied"; + if (resolution.kind !== "valid") return "denied"; + return resolution.authorization.permissions.includes(permission) ? "allowed" : "denied"; + } + /** Return authorized client state, recovering an unexpired lease after hibernation. */ getClientInfo(ws: WebSocket): ClientInfo | null { const { wsManager, log } = this.deps; diff --git a/packages/control-plane/src/session/message-router.ts b/packages/control-plane/src/session/message-router.ts index 8cab30ee8..d3118a04b 100644 --- a/packages/control-plane/src/session/message-router.ts +++ b/packages/control-plane/src/session/message-router.ts @@ -1,6 +1,7 @@ import { sandboxEventSchema, type SandboxEvent } from "@open-inspect/shared/types/sandbox-events"; import { clientRequestIdSchema } from "@open-inspect/shared/types/prompts"; import { clientMessageSchema, type ClientMessage } from "@open-inspect/shared/types/websocket"; +import type { PermissionId } from "@open-inspect/shared/rbac"; import type { Logger } from "../logger"; import type { SessionHistoryPage } from "./event-stream"; import type { Clock, ConnectedClient, SocketRegistry } from "./ports"; @@ -33,6 +34,10 @@ export interface SessionClientCommands; limit?: number; }) => SessionHistoryPage; + authorize: ( + client: Client, + permission: PermissionId + ) => Promise<"allowed" | "denied" | "unavailable">; } export interface SessionMessageRouterDeps { @@ -104,15 +109,19 @@ export class SessionMessageRouter { switch (data.type) { case "prompt": + if (!(await this.authorizeCommand(connection, client, "sessions.collaborate"))) break; await this.deps.clientCommands.submitPrompt(connection, client, data); break; case "cancel_prompt": + if (!(await this.authorizeCommand(connection, client, "sessions.lifecycle"))) break; await this.deps.clientCommands.cancelPrompt(connection, data); break; case "stop": + if (!(await this.authorizeCommand(connection, client, "sessions.lifecycle"))) break; await this.deps.clientCommands.stopExecution(); break; case "typing": + if (!(await this.authorizeCommand(connection, client, "sessions.collaborate"))) break; await this.deps.clientCommands.notifyTyping(); break; case "fetch_history": @@ -137,6 +146,24 @@ export class SessionMessageRouter { } } + private async authorizeCommand( + connection: Connection, + client: Client, + permission: PermissionId + ): Promise { + const result = await this.deps.clientCommands.authorize(client, permission); + if (result === "allowed") return true; + this.deps.sockets.send(connection, { + type: "error", + code: result === "unavailable" ? "AUTHORIZATION_UNAVAILABLE" : "PERMISSION_REQUIRED", + message: + result === "unavailable" + ? "Authorization is temporarily unavailable" + : `Permission required: ${permission}`, + }); + return false; + } + private handleFetchHistory(connection: Connection, client: Client, data: FetchHistory): void { if ( !data.cursor || diff --git a/packages/control-plane/src/session/sandbox-access-reader.ts b/packages/control-plane/src/session/sandbox-access-reader.ts index 714454061..87804cb28 100644 --- a/packages/control-plane/src/session/sandbox-access-reader.ts +++ b/packages/control-plane/src/session/sandbox-access-reader.ts @@ -2,11 +2,14 @@ import type { Logger } from "../logger"; import { decryptStoredAccessValue } from "./sandbox-access"; import type { SandboxRepository } from "./sandbox-repository"; import type { SessionCoreRepository } from "./session-core-repository"; +import { resolveSandboxDashboardUrl, type SandboxDashboardSettings } from "./sandbox-access"; +import { safeParseTunnelUrls } from "./tunnel-urls"; export interface SessionAccessReaderDeps { sessionCoreRepository: SessionCoreRepository; sandboxRepository: SandboxRepository; repoSecretsEncryptionKey: string; + sandboxDashboardSettings: SandboxDashboardSettings; log: Logger; } @@ -44,7 +47,9 @@ export class SessionAccessReader { current.vnc_url !== sandbox.vnc_url || current.vnc_password !== sandbox.vnc_password || current.ttyd_url !== sandbox.ttyd_url || - current.ttyd_token !== sandbox.ttyd_token + current.ttyd_token !== sandbox.ttyd_token || + current.tunnel_urls !== sandbox.tunnel_urls || + current.modal_object_id !== sandbox.modal_object_id ) { return Response.json({ error: "Sandbox access changed; retry" }, { status: 409, headers }); } @@ -57,6 +62,13 @@ export class SessionAccessReader { vnc: current.vnc_url && vncPassword ? { url: current.vnc_url, password: vncPassword } : null, ttyd: current.ttyd_url && ttydToken ? { url: current.ttyd_url, token: ttydToken } : null, + tunnelUrls: current.tunnel_urls + ? safeParseTunnelUrls(current.tunnel_urls, this.deps.log) + : null, + sandboxDashboardUrl: resolveSandboxDashboardUrl( + this.deps.sandboxDashboardSettings, + current.modal_object_id + ), }, { headers } ); diff --git a/packages/control-plane/src/session/server.test.ts b/packages/control-plane/src/session/server.test.ts index 738de431d..70e807db1 100644 --- a/packages/control-plane/src/session/server.test.ts +++ b/packages/control-plane/src/session/server.test.ts @@ -56,6 +56,7 @@ function createHarness() { notifyTyping: vi.fn(async () => undefined), updatePresence: vi.fn(), getHistoryPage: vi.fn(() => ({ items: [], hasMore: false, cursor: null })), + authorize: vi.fn(async () => "allowed" as const), }; const sandbox: SandboxDisconnectMonitor = { getStatus: vi.fn((): "ready" => "ready"), @@ -251,6 +252,30 @@ describe("SessionServer", () => { expect(clientCommands.stopExecution).not.toHaveBeenCalled(); }); + it.each([ + [{ type: "prompt", content: "work", clientRequestId: "request-1" }, "sessions.collaborate"], + [ + { type: "cancel_prompt", messageId: "message-1", clientRequestId: "request-1" }, + "sessions.lifecycle", + ], + [{ type: "stop" }, "sessions.lifecycle"], + ] as const)("rejects %s without its command permission", async (message, permission) => { + const { server, sockets, clientCommands, client } = createHarness(); + vi.mocked(clientCommands.authorize).mockResolvedValue("denied"); + + await server.onMessage("client", JSON.stringify(message)); + + expect(clientCommands.authorize).toHaveBeenCalledWith(client, permission); + expect(sockets.send).toHaveBeenCalledWith("client", { + type: "error", + code: "PERMISSION_REQUIRED", + message: `Permission required: ${permission}`, + }); + expect(clientCommands.submitPrompt).not.toHaveBeenCalled(); + expect(clientCommands.cancelPrompt).not.toHaveBeenCalled(); + expect(clientCommands.stopExecution).not.toHaveBeenCalled(); + }); + it("routes fetch_history and enforces throttling with the injected clock", async () => { const { server, sockets, clientCommands, setNow } = createHarness(); const cursor = { timestamp: 10, id: "event-1", sequence: 2 }; diff --git a/packages/control-plane/test/integration/session-snapshot.test.ts b/packages/control-plane/test/integration/session-snapshot.test.ts index b1d0dd897..0885129cb 100644 --- a/packages/control-plane/test/integration/session-snapshot.test.ts +++ b/packages/control-plane/test/integration/session-snapshot.test.ts @@ -73,6 +73,8 @@ describe("session snapshot synchronization", () => { codeServer: { url: "https://code.example.test", password: "code-secret" }, vnc: { url: "https://desktop.example.test", password: "vnc-secret" }, ttyd: { url: "https://terminal.example.test", token: "terminal-secret" }, + tunnelUrls: null, + sandboxDashboardUrl: null, }); const { ws, messages } = await openClientWs(name, { subscribe: true }); diff --git a/packages/control-plane/test/integration/websocket-client.test.ts b/packages/control-plane/test/integration/websocket-client.test.ts index bb69566ff..01576b5ec 100644 --- a/packages/control-plane/test/integration/websocket-client.test.ts +++ b/packages/control-plane/test/integration/websocket-client.test.ts @@ -243,7 +243,7 @@ describe("Client WebSocket (via SELF.fetch)", () => { ws.close(); }); - it("rejects a custom role that cannot use the complete WebSocket protocol", async () => { + it("rejects a custom role that cannot read the session stream", async () => { const suffix = Date.now(); const name = `ws-client-partial-role-${suffix}`; const userId = `partial-role-user-${suffix}`; @@ -291,7 +291,7 @@ describe("Client WebSocket (via SELF.fetch)", () => { await expect(closed).resolves.toEqual({ code: 4010 }); }); - it("rejects a reconnect after collaborate permission is lost", async () => { + it("keeps the read stream after collaborate permission is lost but rejects prompts", async () => { const name = `ws-client-lost-permission-${Date.now()}`; const userId = `lost-permission-user-${Date.now()}`; await initNamedSession(name); @@ -303,12 +303,34 @@ describe("Client WebSocket (via SELF.fetch)", () => { .run(); const { ws } = await openClientWs(name); - const closed = new Promise<{ code: number }>((resolve) => { - ws.addEventListener("close", (event) => resolve({ code: event.code })); + const subscribed = collectMessages(ws, { + until: (message) => message.type === "subscribed", }); ws.send(JSON.stringify({ type: "subscribe", token, clientId: "lost-permission-client" })); + const snapshot = (await subscribed).find((message) => message.type === "subscribed") as Record< + string, + unknown + >; - await expect(closed).resolves.toEqual({ code: 4010 }); + expect(snapshot).toBeDefined(); + expect(snapshot.session).not.toHaveProperty("sandboxDashboardUrl"); + + const denied = collectMessages(ws, { + until: (message) => message.type === "error", + }); + ws.send( + JSON.stringify({ + type: "prompt", + clientRequestId: crypto.randomUUID(), + content: "not allowed", + }) + ); + + expect((await denied).find((message) => message.type === "error")).toMatchObject({ + code: "PERMISSION_REQUIRED", + message: "Permission required: sessions.collaborate", + }); + ws.close(); }); it("rejects a token after its canonical user is removed", async () => { diff --git a/packages/control-plane/test/integration/websocket-sandbox.test.ts b/packages/control-plane/test/integration/websocket-sandbox.test.ts index f22080f60..625c4e034 100644 --- a/packages/control-plane/test/integration/websocket-sandbox.test.ts +++ b/packages/control-plane/test/integration/websocket-sandbox.test.ts @@ -343,6 +343,8 @@ describe("Sandbox WebSocket (via SELF.fetch)", () => { codeServer: { url: "https://code.test", password: "code-secret" }, vnc: { url: "https://vnc.test", password: "vnc-secret" }, ttyd: { url: "https://terminal.test", token: "terminal-token" }, + tunnelUrls: null, + sandboxDashboardUrl: null, }); sandboxWs!.close(); diff --git a/packages/shared/src/rbac.test.ts b/packages/shared/src/rbac.test.ts index 352098523..bb2430ae3 100644 --- a/packages/shared/src/rbac.test.ts +++ b/packages/shared/src/rbac.test.ts @@ -5,6 +5,7 @@ import { PERMISSION_IDS, SCOPED_PERMISSION_PAIRS, effectiveAuthorizationSchema, + hasScopedPermission, permissionsForBuiltInRole, resolveScopedPermission, replaceMemberRoleInputSchema, @@ -81,6 +82,11 @@ describe("RBAC registry", () => { ).toBe("any"); expect(resolveScopedPermission("automations.manage", ["automations.manage.own"])).toBe("own"); expect(resolveScopedPermission("automations.manage", [])).toBeNull(); + expect(hasScopedPermission("automations.manage", ["automations.manage.any"], false)).toBe(true); + expect(hasScopedPermission("automations.manage", ["automations.manage.own"], true)).toBe(true); + expect(hasScopedPermission("automations.manage", ["automations.manage.own"], false)).toBe( + false + ); }); it("assigns every permission explicitly to Owner", () => { diff --git a/packages/shared/src/rbac.ts b/packages/shared/src/rbac.ts index 078f9d59f..6df70184a 100644 --- a/packages/shared/src/rbac.ts +++ b/packages/shared/src/rbac.ts @@ -77,12 +77,8 @@ export const PERMISSION_IDS = [ /** A permission identifier recognized by the RBAC policy. */ export type PermissionId = (typeof PERMISSION_IDS)[number]; -/** Permissions required to admit a browser WebSocket to the full session protocol. */ -export const SESSION_WEBSOCKET_PERMISSIONS = [ - "sessions.read", - "sessions.collaborate", - "sessions.lifecycle", -] as const satisfies readonly PermissionId[]; +/** Permission required to admit a browser WebSocket to the read synchronization protocol. */ +export const SESSION_WEBSOCKET_CONNECT_PERMISSION = "sessions.read" as const satisfies PermissionId; /** Maps ownership-sensitive capabilities to their workspace-wide and owner-only grants. */ export const SCOPED_PERMISSION_PAIRS = { @@ -112,6 +108,16 @@ export function resolveScopedPermission( return null; } +/** Decides a scoped resource capability from grants plus the caller's ownership result. */ +export function hasScopedPermission( + stem: ScopedPermissionStem, + permissions: readonly PermissionId[], + isOwner: boolean +): boolean { + const scope = resolveScopedPermission(stem, permissions); + return scope === "any" || (scope === "own" && isOwner); +} + const VIEWER_PERMISSIONS = new Set([ "analytics.read", "automations.read", diff --git a/packages/shared/src/types/server-messages.test.ts b/packages/shared/src/types/server-messages.test.ts index 247d7c13e..61f426c97 100644 --- a/packages/shared/src/types/server-messages.test.ts +++ b/packages/shared/src/types/server-messages.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { serverMessageSchema, sessionSnapshotSchema } from "./server-messages"; +import { + redactSessionSnapshotSandboxAccess, + serverMessageSchema, + sessionSnapshotSchema, +} from "./server-messages"; describe("artifact_updated server message", () => { const artifact = { @@ -113,6 +117,31 @@ describe("session view contracts", () => { expect(parsed.timeline.events.map((item) => item.eventId)).toEqual(["event-1"]); }); + it("redacts sandbox locations without mutating the source snapshot", () => { + const snapshot = sessionSnapshotSchema.parse({ + session: { + ...snapshotState, + codeServerUrl: "https://code.example", + vncUrl: "https://vnc.example", + ttydUrl: "https://terminal.example", + tunnelUrls: { "3000": "https://app.example" }, + sandboxDashboardUrl: "https://provider.example", + }, + artifacts: [], + promptQueue: [], + timeline: { events: [], hasMore: false, cursor: null }, + }); + + const redacted = redactSessionSnapshotSandboxAccess(snapshot); + + expect(redacted.session).not.toHaveProperty("codeServerUrl"); + expect(redacted.session).not.toHaveProperty("vncUrl"); + expect(redacted.session).not.toHaveProperty("ttydUrl"); + expect(redacted.session).not.toHaveProperty("tunnelUrls"); + expect(redacted.session).not.toHaveProperty("sandboxDashboardUrl"); + expect(snapshot.session.codeServerUrl).toBe("https://code.example"); + }); + it("rejects malformed stable event envelopes", () => { const snapshot = { session: snapshotState, diff --git a/packages/shared/src/types/server-messages.ts b/packages/shared/src/types/server-messages.ts index 316c2b5ec..432c62356 100644 --- a/packages/shared/src/types/server-messages.ts +++ b/packages/shared/src/types/server-messages.ts @@ -118,6 +118,17 @@ export const sessionSnapshotSchema = z.object({ }); export type SessionSnapshot = z.infer; +/** Removes sandbox location data before a snapshot crosses a read-only boundary. */ +export function redactSessionSnapshotSandboxAccess(snapshot: SessionSnapshot): SessionSnapshot { + const session = { ...snapshot.session }; + delete session.codeServerUrl; + delete session.vncUrl; + delete session.ttydUrl; + delete session.tunnelUrls; + delete session.sandboxDashboardUrl; + return { ...snapshot, session }; +} + const serverMessageUnionSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("pong"), timestamp: z.number() }), sessionSnapshotSchema.extend({ diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx index 9edf40da8..91192a80a 100644 --- a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx @@ -64,6 +64,7 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useSessionSnapshot } from "./session-snapshot-provider"; import { useSessionRename } from "@/hooks/use-session-rename"; import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { resolveSessionCapabilities } from "@/lib/session-capabilities"; type SessionState = ReturnType["sessionState"]; @@ -73,9 +74,7 @@ const DEFAULT_SESSION_STATUS = "created" as const; export default function SessionPage() { const { shortcuts } = useKeyboardShortcuts(); const { hasPermission } = useCurrentUserAuthorization(); - const canCollaborate = hasPermission("sessions.collaborate"); - const canManageLifecycle = hasPermission("sessions.lifecycle"); - const canAccessSandbox = hasPermission("sessions.sandbox_access"); + const capabilities = useMemo(() => resolveSessionCapabilities(hasPermission), [hasPermission]); const initialSnapshot = useSessionSnapshot(); const sessionId = initialSnapshot.session.id; const { @@ -100,10 +99,7 @@ export default function SessionPage() { sendTyping, reconnect, loadOlderEvents, - } = useSessionSocket(sessionId, initialSnapshot, { - collaborate: canCollaborate, - sandboxAccess: canAccessSandbox, - }); + } = useSessionSocket(sessionId, initialSnapshot, capabilities); const { profiles, participants: profiledParticipants } = useSessionParticipantProfiles( sessionId, participants, @@ -151,13 +147,14 @@ export default function SessionPage() { reasoningEffort, loadingEnabledModels, sessionState?.status ?? DEFAULT_SESSION_STATUS, - ready && canCollaborate, + ready && capabilities.collaborate, shortcuts["send-prompt"] ); const [cancellingPromptIds, setCancellingPromptIds] = useState>(new Set()); const cancellingPromptIdsRef = useRef(new Set()); const handleRemoveQueuedPrompt = useCallback( async (messageId: string) => { + if (!capabilities.lifecycle) return; if (cancellingPromptIdsRef.current.has(messageId)) return; const queuedPrompt = promptQueue.find((item) => item.messageId === messageId); if (!queuedPrompt || queuedPrompt.status !== "pending") return; @@ -183,7 +180,7 @@ export default function SessionPage() { setCancellingPromptIds(new Set(cancellingPromptIdsRef.current)); } }, - [cancelPrompt, promptQueue, restorePrompt, setSubmitError] + [cancelPrompt, capabilities.lifecycle, promptQueue, restorePrompt, setSubmitError] ); const [selectedMediaArtifactId, setSelectedMediaArtifactId] = useState(null); @@ -225,7 +222,13 @@ export default function SessionPage() { }, [applyTerminalOpen]); const ttydUrl = sessionState?.ttydUrl; const ttydToken = sessionState?.ttydToken; - const showTerminal = !!(canAccessSandbox && ttydUrl && ttydToken && terminalOpen && !isBelowLg); + const showTerminal = !!( + capabilities.sandboxAccess && + ttydUrl && + ttydToken && + terminalOpen && + !isBelowLg + ); const toggleDetails = useCallback(() => { setIsDetailsOpen((prev) => !prev); @@ -363,9 +366,9 @@ export default function SessionPage() { promptQueue={promptQueue} cancellingPromptIds={cancellingPromptIds} onRemove={handleRemoveQueuedPrompt} - canRemove={canCollaborate} + capabilities={capabilities} /> - {canCollaborate && ( + {capabilities.collaborate && ( {/* Connection error banner */} - {canCollaborate && (authError || connectionError) && ( + {capabilities.read && (authError || connectionError) && (

{authError || connectionError}

- {canManageLifecycle && ( + {capabilities.lifecycle && ( { cleanup(); @@ -14,7 +22,14 @@ describe("DiffRetryNotice", () => { it("retries through the explicit retry endpoint from the banner variant", async () => { const fetchMock = vi.fn().mockResolvedValue(Response.json({}, { status: 200 })); vi.stubGlobal("fetch", fetchMock); - render(); + render( + + ); expect(screen.getByText("timed out")).toBeVisible(); await userEvent.click(screen.getByRole("button", { name: "Retry" })); @@ -30,7 +45,14 @@ describe("DiffRetryNotice", () => { .fn() .mockResolvedValue(Response.json({ error: "Sandbox is not connected" }, { status: 409 })); vi.stubGlobal("fetch", fetchMock); - render(); + render( + + ); await userEvent.click(screen.getByRole("button", { name: "Retry" })); expect(await screen.findByRole("alert")).toHaveTextContent("Sandbox is not connected"); @@ -41,7 +63,14 @@ describe("DiffRetryNotice", () => { .fn() .mockResolvedValue(Response.json({ error: "Still failing" }, { status: 500 })); vi.stubGlobal("fetch", fetchMock); - render(); + render( + + ); expect(screen.getByText("capture failed")).toBeVisible(); await userEvent.click(screen.getByRole("button", { name: "Retry" })); @@ -52,4 +81,17 @@ describe("DiffRetryNotice", () => { }); expect(await screen.findByRole("alert")).toHaveTextContent("Still failing"); }); + + it("hides retry without lifecycle permission even when collaboration is allowed", () => { + render( + + ); + + expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); + }); }); diff --git a/packages/web/src/components/diff-retry-notice.tsx b/packages/web/src/components/diff-retry-notice.tsx index e08cfe89f..a8a60c277 100644 --- a/packages/web/src/components/diff-retry-notice.tsx +++ b/packages/web/src/components/diff-retry-notice.tsx @@ -2,6 +2,7 @@ import { useSessionDiffRetry } from "@/hooks/use-session-diffs"; import { cn } from "@/lib/utils"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; /** * Diff refresh failure notice with a retry action. One render tree for both @@ -13,12 +14,12 @@ export function DiffRetryNotice({ sessionId, message, variant, - canRetry = true, + capabilities, }: { sessionId: string; message: string; variant: "banner" | "inline"; - canRetry?: boolean; + capabilities: SessionCapabilities; }) { const { retry, isRetrying, retryError } = useSessionDiffRetry(sessionId); const banner = variant === "banner"; @@ -35,7 +36,7 @@ export function DiffRetryNotice({

{message}

- {canRetry && ( + {capabilities.lifecycle && (
- {canManageLifecycle && ( + {actions.capabilities.lifecycle && ( { promptQueue={[{ messageId: "queued-1", content: "Review this", status: "pending" }]} cancellingPromptIds={new Set()} onRemove={vi.fn()} - canRemove={false} + capabilities={{ ...FULL_CAPABILITIES, lifecycle: false }} /> ); @@ -29,6 +37,7 @@ describe("QueuedPromptStack", () => { { ); @@ -64,6 +74,7 @@ describe("QueuedPromptStack", () => { promptQueue={[{ messageId: "next", content: "Run next", status: "pending" }]} cancellingPromptIds={new Set(["next"])} onRemove={onRemove} + capabilities={FULL_CAPABILITIES} /> ); @@ -80,6 +91,7 @@ describe("QueuedPromptStack", () => { promptQueue={[{ messageId: "next", content: "Run next", status: "pending" }]} cancellingPromptIds={new Set()} onRemove={onRemove} + capabilities={FULL_CAPABILITIES} /> ); @@ -100,6 +112,7 @@ describe("QueuedPromptStack", () => { ]} cancellingPromptIds={new Set()} onRemove={onRemove} + capabilities={FULL_CAPABILITIES} /> ); diff --git a/packages/web/src/components/queued-prompt-stack.tsx b/packages/web/src/components/queued-prompt-stack.tsx index bc43929fe..60e4a7fd6 100644 --- a/packages/web/src/components/queued-prompt-stack.tsx +++ b/packages/web/src/components/queued-prompt-stack.tsx @@ -2,17 +2,18 @@ import { ClockIcon, XIcon } from "@/components/ui/icons"; import type { PromptQueueItem } from "@open-inspect/shared/types/server-messages"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; export function QueuedPromptStack({ promptQueue, cancellingPromptIds, onRemove, - canRemove = true, + capabilities, }: { promptQueue: PromptQueueItem[]; cancellingPromptIds: ReadonlySet; onRemove: (messageId: string) => void; - canRemove?: boolean; + capabilities: SessionCapabilities; }) { const pendingPrompts = promptQueue.filter((item) => item.status === "pending"); if (pendingPrompts.length === 0) return null; @@ -30,7 +31,7 @@ export function QueuedPromptStack({

{prompt.content}

- {canRemove && ( + {capabilities.lifecycle && ( @@ -234,12 +231,14 @@ export function SessionHeader({ onOpenMedia={onOpenMobileDetails} />
- {showConnectionStatus && ( + {capabilities.read && ( )}
diff --git a/packages/web/src/components/session-prompt-composer.test.tsx b/packages/web/src/components/session-prompt-composer.test.tsx index fbe35398d..f593c8234 100644 --- a/packages/web/src/components/session-prompt-composer.test.tsx +++ b/packages/web/src/components/session-prompt-composer.test.tsx @@ -8,9 +8,17 @@ import userEvent from "@testing-library/user-event"; import * as matchers from "@testing-library/jest-dom/matchers"; import { SessionPromptComposer } from "./session-prompt-composer"; import { MAX_WEB_PROMPT_CHARS } from "@open-inspect/shared/types/websocket"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; expect.extend(matchers); +const FULL_CAPABILITIES: SessionCapabilities = { + read: true, + collaborate: true, + lifecycle: true, + sandboxAccess: true, +}; + vi.mock("@/components/action-bar", () => ({ ActionBar: () =>
, })); @@ -38,6 +46,7 @@ function ComposerHarness({ status = "active", submitError = null, withSkill = false, + canManageLifecycle = true, }: { initialValue?: string; isProcessing?: boolean; @@ -46,6 +55,7 @@ function ComposerHarness({ status?: "active" | "archived" | "cancelled"; submitError?: string | null; withSkill?: boolean; + canManageLifecycle?: boolean; }) { const [value, setValue] = useState(initialValue); const inputRef = useRef(null); @@ -58,6 +68,7 @@ function ComposerHarness({ artifacts: [], onArchive: vi.fn(), onUnarchive: vi.fn(), + capabilities: { ...FULL_CAPABILITIES, lifecycle: canManageLifecycle }, }} prompt={{ value, @@ -172,6 +183,16 @@ describe("SessionPromptComposer", () => { expect(screen.getByTitle(/Send/)).toBeDisabled(); }); + it("hides stop controls without lifecycle permission", () => { + render( + + ); + + expect( + screen.queryByTitle("Stop current prompt; queued prompts will continue") + ).not.toBeInTheDocument(); + }); + it("shows an inline submission error", () => { render(); expect(screen.getByRole("alert")).toHaveTextContent("The prompt queue is full"); diff --git a/packages/web/src/components/session-prompt-composer.tsx b/packages/web/src/components/session-prompt-composer.tsx index 50bf53179..9b46f5915 100644 --- a/packages/web/src/components/session-prompt-composer.tsx +++ b/packages/web/src/components/session-prompt-composer.tsx @@ -15,6 +15,7 @@ import type { SessionStatus } from "@open-inspect/shared/types/sessions"; import { MAX_WEB_PROMPT_CHARS } from "@open-inspect/shared/types/websocket"; import type { PromptSkillSuggestionSource } from "@/lib/prompt-skill-completion"; import type { ModelCategory, ReasoningEffort, ValidModel } from "@open-inspect/shared/models"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; type SessionPromptComposerProps = { session: { @@ -24,7 +25,7 @@ type SessionPromptComposerProps = { primaryRepo?: { repoOwner: string; repoName: string } | null; onArchive: () => void | Promise; onUnarchive: () => void | Promise; - canManageLifecycle?: boolean; + capabilities: SessionCapabilities; }; prompt: { value: string; @@ -106,7 +107,7 @@ export function SessionPromptComposer({ primaryRepo={session.primaryRepo} onArchive={session.onArchive} onUnarchive={session.onUnarchive} - canManageLifecycle={session.canManageLifecycle} + capabilities={session.capabilities} />
@@ -167,7 +168,7 @@ export function SessionPromptComposer({ > - {prompt.isProcessing && ( + {prompt.isProcessing && session.capabilities.lifecycle && (
{/* Code Server */} - {canAccessSandbox && sessionState.codeServerUrl && ( + {capabilities.sandboxAccess && sessionState.codeServerUrl && (
@@ -189,7 +186,7 @@ export function SessionRightSidebarContent({ )} {/* Tunnel URLs */} - {canAccessSandbox && + {capabilities.sandboxAccess && sessionState.tunnelUrls && Object.keys(sessionState.tunnelUrls).length > 0 && (
@@ -252,7 +249,7 @@ export function SessionRightSidebarContent({ sessionId={sessionId} message={diffView.message ?? ""} variant="inline" - canRetry={canRetryDiff} + capabilities={capabilities} /> )}
@@ -297,9 +294,7 @@ export function SessionRightSidebar({ diffLoading, selectedDiff, onOpenDiff, - canAccessSandbox, - canManageLifecycle, - canRetryDiff, + capabilities, }: SessionRightSidebarProps) { return ( ); diff --git a/packages/web/src/components/session-sidebar.tsx b/packages/web/src/components/session-sidebar.tsx index dce762aa8..b4bee5278 100644 --- a/packages/web/src/components/session-sidebar.tsx +++ b/packages/web/src/components/session-sidebar.tsx @@ -74,7 +74,6 @@ export function SessionSidebar({ const pathname = usePathname(); const router = useRouter(); const isMobile = useIsMobile(); - const { hasPermission } = useCurrentUserAuthorization(); const currentSessionId = pathname?.startsWith("/session/") ? pathname.split("/")[2] : null; diff --git a/packages/web/src/components/sidebar/metadata-section.test.tsx b/packages/web/src/components/sidebar/metadata-section.test.tsx index d02b00141..8a5573f63 100644 --- a/packages/web/src/components/sidebar/metadata-section.test.tsx +++ b/packages/web/src/components/sidebar/metadata-section.test.tsx @@ -1,10 +1,11 @@ // @vitest-environment jsdom /// +import type { ComponentProps } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; -import { MetadataSection } from "./metadata-section"; +import { MetadataSection as MetadataSectionComponent } from "./metadata-section"; expect.extend(matchers); @@ -13,6 +14,15 @@ expect.extend(matchers); // matching leftover DOM from earlier renders. afterEach(cleanup); +function MetadataSection({ + canManageLifecycle = true, + ...props +}: Omit, "canManageLifecycle"> & { + canManageLifecycle?: boolean; +}) { + return ; +} + vi.mock("next/link", () => ({ default: ({ children, href, ...props }: React.ComponentProps<"a">) => ( diff --git a/packages/web/src/components/sidebar/metadata-section.tsx b/packages/web/src/components/sidebar/metadata-section.tsx index 4d56aa061..f17d74f6c 100644 --- a/packages/web/src/components/sidebar/metadata-section.tsx +++ b/packages/web/src/components/sidebar/metadata-section.tsx @@ -52,7 +52,7 @@ interface MetadataSectionProps { warnings?: WarningEvent[]; parentSessionId?: string | null; totalCost?: number; - canManageLifecycle?: boolean; + canManageLifecycle: boolean; } /** @@ -109,7 +109,7 @@ export function MetadataSection({ warnings = [], parentSessionId, totalCost, - canManageLifecycle = true, + canManageLifecycle, }: MetadataSectionProps) { const [copied, setCopied] = useState(false); diff --git a/packages/web/src/hooks/use-sandbox-access.ts b/packages/web/src/hooks/use-sandbox-access.ts index 28c943041..f814a6505 100644 --- a/packages/web/src/hooks/use-sandbox-access.ts +++ b/packages/web/src/hooks/use-sandbox-access.ts @@ -10,14 +10,20 @@ const sandboxAccessSchema = z codeServer: z.object({ url: z.string(), password: z.string() }).nullable(), vnc: z.object({ url: z.string(), password: z.string() }).nullable(), ttyd: z.object({ url: z.string(), token: z.string() }).nullable(), + // Optional during rolling deployments where the control plane predates + // these protected access fields. + tunnelUrls: z.record(z.string(), z.string()).nullable().optional().default(null), + sandboxDashboardUrl: z.string().nullable().optional().default(null), }) - .transform(({ codeServer, vnc, ttyd }) => ({ + .transform(({ codeServer, vnc, ttyd, tunnelUrls, sandboxDashboardUrl }) => ({ codeServerUrl: codeServer?.url ?? null, codeServerPassword: codeServer?.password ?? null, vncUrl: vnc?.url ?? null, vncPassword: vnc?.password ?? null, ttydUrl: ttyd?.url ?? null, ttydToken: ttyd?.token ?? null, + tunnelUrls, + sandboxDashboardUrl, })); type SandboxAccess = z.infer; diff --git a/packages/web/src/hooks/use-session-socket.test.tsx b/packages/web/src/hooks/use-session-socket.test.tsx index d08cf333f..7a0347ab9 100644 --- a/packages/web/src/hooks/use-session-socket.test.tsx +++ b/packages/web/src/hooks/use-session-socket.test.tsx @@ -12,6 +12,14 @@ import type { import type * as SwrModule from "swr"; import { isUnarchivedSessionListKey } from "@/lib/session-list"; import { useSessionSocket } from "./use-session-socket"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; + +const FULL_CAPABILITIES = { + read: true, + collaborate: true, + lifecycle: true, + sandboxAccess: true, +} satisfies SessionCapabilities; type SubscribedMessage = Extract; @@ -139,30 +147,34 @@ describe("useSessionSocket", () => { vi.restoreAllMocks(); }); - it("keeps the HTTP snapshot available without collaboration or sandbox requests", async () => { + it("keeps read synchronization available without collaboration or sandbox access", async () => { const fetchMock = vi.mocked(fetch); const snapshot = createSnapshot(); snapshot.session.title = "Read-only snapshot"; const { result } = renderHook(() => useSessionSocket("session-1", snapshot, { + read: true, collaborate: false, + lifecycle: false, sandboxAccess: false, }) ); - await act(async () => { - await Promise.resolve(); - }); + await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); expect(result.current.sessionState?.title).toBe("Read-only snapshot"); expect(result.current.connected).toBe(false); - expect(FakeWebSocket.instances).toHaveLength(0); - expect(fetchMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith( + "/api/sessions/session-1/ws-token", + expect.objectContaining({ method: "POST" }) + ); }); it("keeps sendPrompt pending until the server acknowledges the queued prompt", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -213,7 +225,9 @@ describe("useSessionSocket", () => { }); it("keeps cancelPrompt pending until the matching server acknowledgement", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); const socket = FakeWebSocket.instances[0]; act(() => { @@ -261,7 +275,9 @@ describe("useSessionSocket", () => { }); it("returns a correlated cancellation race error without treating it as success", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); const socket = FakeWebSocket.instances[0]; act(() => { @@ -288,7 +304,9 @@ describe("useSessionSocket", () => { }); it("sends correlated prompts without feature negotiation", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); const socket = FakeWebSocket.instances[0]; @@ -309,7 +327,9 @@ describe("useSessionSocket", () => { }); it("ignores unrelated acknowledgements and errors while a correlated prompt is pending", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); const socket = FakeWebSocket.instances[0]; act(() => { @@ -348,7 +368,9 @@ describe("useSessionSocket", () => { }); it("immediately rejects a correlated invalid prompt with the server message", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); const socket = FakeWebSocket.instances[0]; act(() => { @@ -376,7 +398,9 @@ describe("useSessionSocket", () => { }); it("waits for subscription and reports when a prompt cannot be sent", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -412,7 +436,9 @@ describe("useSessionSocket", () => { }); it("reuses the caller's request identity when retrying after a reconnect", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); const firstSocket = FakeWebSocket.instances[0]; act(() => { @@ -478,7 +504,9 @@ describe("useSessionSocket", () => { }); it("hydrates artifacts from the subscribed payload", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -527,7 +555,9 @@ describe("useSessionSocket", () => { }); it("hydrates screenshot metadata from subscribed artifacts", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -584,7 +614,9 @@ describe("useSessionSocket", () => { }); it("revalidates the sidebar session list on title updates", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -605,7 +637,9 @@ describe("useSessionSocket", () => { }); it("hydrates replayed assistant text before completion when storage ordering is tied", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -664,7 +698,9 @@ describe("useSessionSocket", () => { }); it("hydrates video metadata from subscribed artifacts", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -729,7 +765,9 @@ describe("useSessionSocket", () => { }); it("drops wrong-type metadata fields during narrowing", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -778,7 +816,9 @@ describe("useSessionSocket", () => { }); it("replaces stale artifacts with the subscribed snapshot", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -814,7 +854,9 @@ describe("useSessionSocket", () => { }); it("updates sessionState.branchName from session_branch without mutating the sidebar cache", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -838,7 +880,9 @@ describe("useSessionSocket", () => { }); it("routes a repo-scoped session_branch to the matching member, mirroring the scalar only for the primary", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -917,7 +961,9 @@ describe("useSessionSocket", () => { }); it("ignores an unscoped session_branch for a multi-repo session", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -974,7 +1020,9 @@ describe("useSessionSocket", () => { }); it("updates sessionState.sandboxDashboardUrl from sandbox_dashboard_url", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -1001,7 +1049,9 @@ describe("useSessionSocket", () => { }); it("clears credentials on spawn and terminal statuses without dropping diagnostic links early", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -1054,7 +1104,9 @@ describe("useSessionSocket", () => { }); it("clears dashboard URL only for replacement starts, not sandbox errors", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -1106,7 +1158,9 @@ describe("useSessionSocket", () => { }); it("prepends new artifacts and replaces duplicates by id", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -1192,7 +1246,9 @@ describe("useSessionSocket", () => { }); it("applies artifact_updated in place and revalidates the session list", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -1256,7 +1312,9 @@ describe("useSessionSocket", () => { }); it("does not revalidate the session list for non-PR artifacts", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -1291,7 +1349,9 @@ describe("useSessionSocket", () => { }); it("derives prState from tracked lifecycle metadata over the legacy state key", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); diff --git a/packages/web/src/hooks/use-session-socket.ts b/packages/web/src/hooks/use-session-socket.ts index ec9d622c6..4eed409f3 100644 --- a/packages/web/src/hooks/use-session-socket.ts +++ b/packages/web/src/hooks/use-session-socket.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useReducer, useRef } from "react"; import { mutate } from "swr"; import { useSessionTransport } from "@/hooks/use-session-transport"; import { useSandboxAccess } from "@/hooks/use-sandbox-access"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; import { ingestLiveSandboxEvent, pendingToTokenEvent, @@ -100,10 +101,7 @@ interface PendingCorrelatedRequest { export function useSessionSocket( sessionId: string, initialSnapshot: SessionSnapshot, - capabilities: { collaborate: boolean; sandboxAccess: boolean } = { - collaborate: true, - sandboxAccess: true, - } + capabilities: SessionCapabilities ): UseSessionSocketReturn { const [state, dispatch] = useReducer( sessionSocketReducer, @@ -242,7 +240,7 @@ export function useSessionSocket( onMessage: handleMessage, onClose: handleClose, }, - capabilities.collaborate + capabilities.read ); const { isOpen, send, reconnect, markHealthy } = transport; diff --git a/packages/web/src/hooks/use-session-transport.test.tsx b/packages/web/src/hooks/use-session-transport.test.tsx index dcf40ee0f..a3e3a0b36 100644 --- a/packages/web/src/hooks/use-session-transport.test.tsx +++ b/packages/web/src/hooks/use-session-transport.test.tsx @@ -129,6 +129,30 @@ describe("useSessionTransport", () => { expect(FakeWebSocket.instances).toHaveLength(0); }); + it("resets transport state across enabled to disabled to enabled", async () => { + const rendered = renderHook( + ({ enabled }) => useSessionTransport("session-1", { onMessage, onClose }, enabled), + { initialProps: { enabled: true } } + ); + await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); + act(() => FakeWebSocket.instances[0].open()); + await waitFor(() => expect(rendered.result.current.connected).toBe(true)); + + rendered.rerender({ enabled: false }); + + await waitFor(() => { + expect(rendered.result.current.connected).toBe(false); + expect(rendered.result.current.connecting).toBe(false); + }); + expect(rendered.result.current.isOpen()).toBe(false); + expect(onClose).toHaveBeenCalledTimes(1); + + rendered.rerender({ enabled: true }); + await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2)); + act(() => FakeWebSocket.instances[1].open()); + await waitFor(() => expect(rendered.result.current.connected).toBe(true)); + }); + it("forwards schema-valid messages to onMessage", async () => { const { socket } = await openSocket(); diff --git a/packages/web/src/hooks/use-session-transport.ts b/packages/web/src/hooks/use-session-transport.ts index 3e50eff3e..dbe266fc2 100644 --- a/packages/web/src/hooks/use-session-transport.ts +++ b/packages/web/src/hooks/use-session-transport.ts @@ -23,7 +23,6 @@ const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8787"; const WS_CLOSE_AUTH_REQUIRED = 4001; const WS_CLOSE_SESSION_EXPIRED = 4002; const WS_CLOSE_INVALID_MESSAGE = 4004; -const WS_CLOSE_AUTHORIZATION_REVOKED = 4010; const MAX_RECONNECT_ATTEMPTS = 5; const RECONNECT_BASE_DELAY_MS = 1000; @@ -390,22 +389,39 @@ export function useSessionTransport( reconnectAttempts.current = 0; }, []); - // Connect on mount + // Track the actual component lifetime separately from capability changes. useEffect(() => { mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // Connect while read transport is allowed. Cleanup is also the explicit + // enabled -> disabled transition: invalidate pending work, notify the + // protocol layer, and reset all transport state before a later re-enable. + useEffect(() => { if (enabled) connect(); return () => { - mountedRef.current = false; + const discarded = wsRef.current; + const hadActiveAttempt = discarded !== null || connectingEpochRef.current !== null; invalidateInFlightConnect(); if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; } - const discarded = wsRef.current; if (discarded) { wsRef.current = null; discarded.close(); } + wsTokenRef.current = null; + reconnectAttempts.current = 0; + setConnected(false); + setConnecting(false); + setAuthError(null); + setConnectionError(null); + if (hadActiveAttempt) handlersRef.current.onClose?.(); }; }, [connect, enabled, invalidateInFlightConnect]); diff --git a/packages/web/src/lib/automation-authorization.ts b/packages/web/src/lib/automation-authorization.ts index 798f7e2dc..87a0704ba 100644 --- a/packages/web/src/lib/automation-authorization.ts +++ b/packages/web/src/lib/automation-authorization.ts @@ -1,5 +1,5 @@ import { - resolveScopedPermission, + hasScopedPermission, type EffectiveAuthorization, type ScopedPermissionStem, } from "@open-inspect/shared/rbac"; @@ -12,6 +12,9 @@ export function canAccessAutomation( automation: Pick ): boolean { if (!authorization) return false; - const scope = resolveScopedPermission(stem, authorization.permissions); - return scope === "any" || (scope === "own" && automation.userId === authorization.userId); + return hasScopedPermission( + stem, + authorization.permissions, + automation.userId === authorization.userId + ); } diff --git a/packages/web/src/lib/session-capabilities.ts b/packages/web/src/lib/session-capabilities.ts new file mode 100644 index 000000000..b7abe004e --- /dev/null +++ b/packages/web/src/lib/session-capabilities.ts @@ -0,0 +1,20 @@ +import type { PermissionId } from "@open-inspect/shared/rbac"; + +/** Required session capability model shared by the page and every privileged child control. */ +export interface SessionCapabilities { + read: boolean; + collaborate: boolean; + lifecycle: boolean; + sandboxAccess: boolean; +} + +export function resolveSessionCapabilities( + hasPermission: (permission: PermissionId) => boolean +): SessionCapabilities { + return { + read: hasPermission("sessions.read"), + collaborate: hasPermission("sessions.collaborate"), + lifecycle: hasPermission("sessions.lifecycle"), + sandboxAccess: hasPermission("sessions.sandbox_access"), + }; +} diff --git a/public/docs/internal/2026-08-28-rbac-design.md b/public/docs/internal/2026-08-28-rbac-design.md deleted file mode 100644 index 626d894e1..000000000 --- a/public/docs/internal/2026-08-28-rbac-design.md +++ /dev/null @@ -1,815 +0,0 @@ -# Design: Role-Based Access Control - -**Date:** 2026-08-28 - -**Status:** Proposed - -**Research:** [2026-08-28-rbac-research.md](./2026-08-28-rbac-research.md) - -## Summary - -Open-Inspect will add workspace-level RBAC to its existing single-installation identity model. Each -canonical human user is assigned exactly one role. A role contains a set of permissions selected -from a code-owned registry. Four protected built-in roles provide safe defaults. The storage and -resolution model also supports existing custom roles, but custom-role creation and editing are -deferred beyond this foundation. - -Authorization will be enforced in the control plane after authentication and before business logic. -The web will receive effective permissions for navigation and control affordances, but client checks -will remain advisory. Sessions are workspace-wide resources governed by operation permissions, as -specified in -[Workspace-Wide Session Authorization](./2026-08-30-workspace-wide-session-authorization-design.md). -Bot calls will be limited by both a fixed service capability ceiling and, when acting for a human, -that canonical user's current role. - -This design retains one workspace per deployment. It does not add multiple organizations or -per-repository user grants. The SCM App installation continues to define the repository universe; -RBAC determines which application actions a user may perform within that universe. - -## Goals - -- Assign different capability sets to individual canonical users. -- Provide protected Owner, Administrator, Member, and Viewer roles. -- Resolve and assign persisted custom roles from a fixed permission registry. -- Enforce permissions consistently across HTTP routes, session WebSockets, bots, and settings. -- Distinguish authentication, admission, attribution, resource relationships, and authorization. -- Preserve existing installation access during migration without leaving the workspace ownerless. -- Make role assignment and privileged operations durably auditable. -- Apply role changes promptly to new requests and bounded-lifetime live connections. -- Keep the authorization API explicit, typed, testable, and deny-by-default. - -## Non-Goals - -- Multiple workspaces or organizations in one deployment. -- User/group grants for individual repositories or environments. -- Synchronizing roles from GitHub, Google, Slack, Linear, or an identity provider. -- Treating source-control permissions as Open-Inspect roles. -- A general policy language, conditional expressions, deny rules, or arbitrary customer-defined - permission identifiers. -- Billing plans, quotas, approval workflows, or separation-of-duty constraints. -- Modeling Cloudflare, Modal, Terraform, or GitHub deployment operators as application users. -- Changing sandbox-to-control-plane or control-plane-to-Modal machine authentication. -- Making secret values readable after storage. - -## Terminology - -| Term | Meaning | -| --------------------- | --------------------------------------------------------------------------------- | -| Workspace | The singleton administrative boundary represented by one Open-Inspect deployment. | -| Principal | An authenticated human user, first-party service, or session-bound sandbox. | -| Actor | A provider identity asserted by a bot service on behalf of a human. | -| Role | A named collection of registered permissions. | -| Built-in role | A protected role shipped by the application with code-defined permissions. | -| Custom role | A workspace-defined role composed from registered permissions. | -| Permission | A stable `resource.action` identifier checked by backend policy. | -| Relationship | Context such as automation ownership used alongside a scoped permission. | -| Capability ceiling | The maximum permission set a first-party service can exercise. | -| Effective permissions | The permissions produced by the current role, bounded by principal policy. | - -## Decisions - -| Area | Decision | -| ---------------- | ---------------------------------------------------------------------------------------- | -| Tenancy | One implicit workspace per deployment. | -| User assignment | Exactly one role per canonical user. | -| Role model | Four protected built-ins plus custom roles. | -| Permission model | Fixed allow-only registry owned in shared code. Missing permission denies. | -| Enforcement | Control plane is authoritative; web checks are presentation only. | -| Resource scoping | Workspace-wide sessions plus contextual own/any automation actions. | -| Repository scope | SCM installation defines visibility; role permissions govern app operations. | -| Services | Static service ceilings; actor-backed calls use ceiling/actor intersection. | -| Sandboxes | Existing session-bound capability model remains separate from human RBAC. | -| Role changes | Immediate for HTTP; short authorization leases bound live browser connections. | -| Audit | Durable audit events for RBAC changes and sensitive mutations; structured denial logs. | -| Owner bootstrap | Every deployment requires an explicit operator bootstrap after the Owner signs in. | -| Migration | Existing canonical users become Administrator; the operator explicitly bootstraps Owner. | - -## Authorization Model - -### Built-in roles - -The built-in roles are stable system records. Their names and permission sets are defined in code -and cannot be deleted or edited through the application. - -| Role | Intended capability | -| ------------- | --------------------------------------------------------------------------------------------- | -| Owner | Full application access, role management, member management, and ownership transfer. | -| Administrator | Full operational access except ownership transfer and protected Owner changes. | -| Member | Create and operate sessions and automations; use shared targets; no sensitive administration. | -| Viewer | Read shared operational state and session output; no launches or shared-resource mutations. | - -Owner is not represented by a wildcard. It receives every registered permission explicitly when -permissions are resolved. This makes newly introduced permissions visible in review and prevents -custom permission strings from becoming executable. - -### Custom roles - -The data model and permission resolver retain support for persisted custom roles so assignments and -effective authorization do not depend on built-in role keys. This foundation exposes custom roles -through read and assignment APIs only; creating, editing, and deleting them is deferred until there -is a concrete administration workflow. Persisted custom permissions must be registry members, cannot -include `workspace.transfer_ownership`, and remain allow-only without inheritance or deny entries. - -One role per user avoids ambiguous permission union, ordering, and deny precedence. A later group or -multi-role system can expand assignment cardinality without changing permission identifiers or route -checks. - -### Permission registry - -Permissions are exported from `@open-inspect/shared` as stable identifiers and protected built-in -role sets. Built-in policy changes deploy with code and do not require a data migration. Persisted -`role_permissions` rows are the runtime authority only for workspace-defined custom roles. Unknown -identifiers fail role validation and are ignored during effective-permission resolution. Permission -IDs are never reused for different semantics. - -### Permission catalog - -#### Workspace and identity - -| Permission | Actions | -| ------------------------------ | --------------------------------------------------------------------- | -| `workspace.members.read` | List users, identities, roles, and assignment state. | -| `workspace.members.manage` | Assign roles other than Owner; suspend or restore application access. | -| `workspace.roles.read` | List role definitions and permission catalog. | -| `workspace.transfer_ownership` | Assign/remove Owner while preserving at least one Owner. | - -#### Repositories and environments - -| Permission | Actions | -| ------------------------------ | ----------------------------------------------------------------- | -| `repositories.read` | List installed repositories, branches, and metadata. | -| `repositories.use` | Select repositories as session or automation targets. | -| `repositories.settings.manage` | Change repository SCM, sandbox, and integration overrides. | -| `repositories.secrets.manage` | Create, update, or delete repository secrets. | -| `repositories.images.manage` | Toggle or trigger repository image builds. | -| `environments.read` | List and inspect environments and memberships. | -| `environments.use` | Select environments as session or automation targets. | -| `environments.manage` | Create, update, or delete environments and repository membership. | -| `environments.settings.manage` | Change environment integration and sandbox overrides. | -| `environments.secrets.manage` | Create, update, delete, or import environment secrets. | -| `environments.images.manage` | Toggle or trigger environment image builds. | - -#### Sessions - -| Permission | Actions | -| ------------------------- | --------------------------------------------------------------------- | -| `sessions.create` | Create a session using an allowed target. | -| `sessions.read` | Read every workspace session. | -| `sessions.collaborate` | Prompt, attach files, and connect to every workspace session. | -| `sessions.lifecycle` | Rename, archive, unarchive, stop, cancel, and refresh any session. | -| `sessions.delete` | Delete any workspace session. | -| `sessions.sandbox_access` | Obtain terminal, VNC, code-server, or sandbox access for any session. | - -Session creator and participant data are attribution and runtime identity, not authorization. -Read-state changes require `sessions.read` and always mutate only the caller's own read state. - -#### Automations and analytics - -| Permission | Actions | -| ------------------------- | ---------------------------------------------------------------------------- | -| `automations.read` | List automation definitions and run history. | -| `automations.create` | Create an automation with allowed targets and provider mode. | -| `automations.manage.own` | Edit, pause, resume, rotate keys, or delete automations created by the user. | -| `automations.manage.any` | Manage any automation. | -| `automations.trigger.own` | Manually execute an automation created by the user. | -| `automations.trigger.any` | Manually execute any automation. | -| `analytics.read` | View installation-wide session, repository, user, and PR analytics. | - -#### Models, integrations, and execution configuration - -| Permission | Actions | -| --------------------------- | -------------------------------------------------------------------------- | -| `models.preferences.manage` | Change enabled model preferences. | -| `provider_accounts.read` | View provider account metadata, status, and defaults. | -| `provider_accounts.manage` | Connect, reconnect, rename, verify, enable, disable, and default accounts. | -| `integrations.read` | View integration, SCM, sandbox, and commit-signing metadata. | -| `integrations.manage` | Change global integration and sandbox settings. | -| `scm_settings.manage` | Change deployment-wide SCM settings. | -| `commit_signing.manage` | Configure or remove deployment-wide signing material. | -| `global_secrets.manage` | Create, update, or delete global secrets. | -| `image_builds.read` | View repository/environment image build status and history. | - -#### Extensibility - -| Permission | Actions | -| --------------------------- | ------------------------------------------------------------------------- | -| `skills.read` | List shared managed skills. | -| `skills.manage` | Import, edit, assign, reimport, enable, disable, or delete shared skills. | -| `skill_profiles.manage_own` | Manage only the caller's skill profiles. | -| `mcp_servers.read` | List MCP server definitions. | -| `mcp_servers.manage` | Create, update, or delete MCP server definitions. | - -Personal keyboard shortcuts and browser-local appearance require only an authenticated, active user. -They do not need role permissions because they cannot affect another user or shared execution. - -### Default role matrix - -The table groups permissions for readability; the registry stores individual identifiers. - -| Capability group | Owner | Administrator | Member | Viewer | -| -------------------------------------------------------- | :---: | :-----------: | :------: | :----: | -| Workspace, member, role, and audit read | Yes | Yes | No | No | -| Manage members | Yes | Yes | No | No | -| Transfer Owner role | Yes | No | No | No | -| Read repositories and environments | Yes | Yes | Yes | Yes | -| Use repositories and environments | Yes | Yes | Yes | No | -| Read image-build status and history | Yes | Yes | Yes | Yes | -| Manage environments/settings/images | Yes | Yes | No | No | -| Manage global/repository/environment secrets | Yes | Yes | No | No | -| Create sessions | Yes | Yes | Yes | No | -| Read any session | Yes | Yes | Yes | Yes | -| Collaborate in any session | Yes | Yes | Yes | No | -| Perform session lifecycle operations | Yes | Yes | Yes | No | -| Delete sessions | Yes | Yes | Yes | No | -| Obtain sandbox access | Yes | Yes | Yes | No | -| Read automations | Yes | Yes | Yes | Yes | -| Create/manage/trigger automations | Yes | Yes | Own only | No | -| Read analytics | Yes | Yes | Yes | Yes | -| Manage models/provider accounts/integrations/SCM/signing | Yes | Yes | No | No | -| Read shared skills and MCP servers | Yes | Yes | Yes | Yes | -| Manage shared skills and MCP servers | Yes | Yes | No | No | -| Manage own skill profiles | Yes | Yes | Yes | No | -| Manage personal preferences | Yes | Yes | Yes | Yes | - -Viewer receives `sessions.read` but no collaborate or lifecycle permission. Member receives every -non-administrative session operation across the workspace. Administrator preserves the existing -broad operational behavior. - -## Data Model - -### Tables - -```sql -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)) -); - -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); -``` - -Built-in roles have stable `key` values: `owner`, `administrator`, `member`, and `viewer`; their -permission sets come from the shared code registry and have no `role_permissions` rows. Custom roles -have `key = NULL`, and their permission rows are the runtime authority. IDs are opaque; role names -are display values. This foundation does not expose custom-role mutations. - -`users` gains: - -```sql -ALTER TABLE users ADD COLUMN suspended_at INTEGER; -``` - -Suspension records the time access was disabled without deleting identities or historical -attribution. A null value means the user is active. - -Every canonical identity is an active workspace member unless suspended. The RBAC migration seeds -the built-in roles, assigns Administrator to every existing canonical user, and then creates the -default-role trigger. Every identity created afterward receives Member, including identities first -observed through a bot. Identity creation and default role assignment are one database-triggered -workflow. Authorization denies a missing assignment; ordinary sign-in and identity resolution never -repair authorization corruption implicitly. - -Initial ownership is assigned only by the root operator CLI after the intended Owner has signed in -once. The operator supplies the canonical user ID, not an email or browser credential. One temporary -SQL file and one Wrangler D1 execution validate the RBAC schema, unsuspended user, exact assignment, -and absence of another unsuspended Owner before atomically writing a redacted `operator-cli` audit -event and assigning `role_builtin_owner`. The final SQL guard verifies the exact generated audit ID -and aborts the operation if the resulting state is inconsistent. Re-running for the current -unsuspended Owner is a no-op and writes nothing. Ownership changes after initialization use the -authenticated member API. - -### Storage ownership - -- D1 is the source of truth for roles, assignments, status, custom-role grants, and audit events. -- Shared code defines the permission catalog and built-in role grants; persisted permission rows are - the runtime grant authority for custom roles. -- Session creator attribution remains in D1 and is not an authorization relationship. -- Participant attribution remains in the Session Durable Object for message identity, presence, SCM - metadata, and WebSocket tokens. -- No role or permission set is copied into sessions, automations, or provider accounts. - -## Policy Engine - -### Interface - -Authorization is invoked through one control-plane service rather than direct role-table queries in -handlers: - -```ts -type AuthorizationRequest = { - principal: Principal; - permission: PermissionId; - resource?: AuthorizationResource; -}; - -type AuthorizationDecision = { - allowed: boolean; - reason: AuthorizationReason; - actorUserId: string | null; -}; -``` - -The engine exposes `requirePermission()` for ordinary checks and an automation resource helper for -owner-scoped automation policy. Denial throws a typed `403` error with a stable reason code. -Authentication failures remain `401`; missing resources remain `404` after permission admission. - -### Human decision flow - -1. Require an active canonical user. -2. Load the user's role assignment and registered permission set. -3. Deny if no assignment exists. -4. Check the requested permission. -5. For owner-scoped automation permissions, load the automation owner. -6. Return an allow/deny decision with a stable reason. - -### Service decision flow - -Each service has a code-defined ceiling: - -- `web` may proxy browser-auth and discovery operations only; browser application routes authorize - the human user principal produced by composed authentication. -- `github-bot` may read repository/environment launch metadata, create sessions, read, prompt, or - stop workspace sessions, and post GitHub automation events. -- `slack-bot` may read launch catalogs/preferences, create sessions, operate sessions mapped to its - Slack thread, upload/download session media, and post Slack events. -- `linear-bot` may read launch catalogs/preferences, create sessions, and operate sessions mapped to - its Linear issue/agent session. - -For an actor-backed service request: - -```text -effective = service ceiling ∩ actor role permissions -``` - -The actor must resolve to an active canonical user with a role assignment. Service-authenticated -identity enrollment resolves or creates the canonical identity before business authorization and -idempotently assigns the migration default: Administrator for identities captured by the migration, -Member afterward. A first bot interaction can therefore proceed with Member capabilities but can -never claim Owner. Provider webhook verification and GitHub collaborator checks remain additional -admission conditions, never substitutes for application authorization. - -Actorless callbacks, normalized webhook events, and automation triggers use narrow service-only -permissions declared for their exact endpoints. They cannot use broad `user-or-service` management -routes. - -### Sandbox decision flow - -Sandbox authentication remains a scoped capability. A valid sandbox principal can call only route -operations explicitly designated for a sandbox bound to the same session. It does not inherit the -session creator's role and does not gain workspace permissions. Human role changes do not terminate -an executing sandbox, but they can remove human access to its session and controls. - -### Session authorization and identity - -Session operations are workspace-scoped. A user with a session operation permission may apply it to -every session, regardless of creator or participant identity. Deletion is also workspace-scoped. - -`sessions.user_id` retains immutable creator attribution for display, filtering, auditing, and -credential lineage. Session Durable Object participants retain message identity, presence, SCM -metadata, and WebSocket token ownership. Neither is an authorization grant. - -Creating a WebSocket token or sending a prompt requires `sessions.collaborate`. WebSocket -subscription rechecks the represented canonical user's active role and collaboration permission. -Private, invitation-only, participant-restricted, and creator-only session behavior is deferred. - -### Automation execution authority - -Automation definitions retain a canonical owner. Every invocation reauthorizes current state rather -than replaying stored creator authority: - -| Trigger | Initiating actor | Execution principal | Required current authority | -| ------------ | ----------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------- | -| Manual | Requesting user | Requesting user | own/any trigger, target use, session create | -| Schedule | Scheduler service | Automation owner | active owner, manage-own, target use, session create | -| Webhook key | Narrow webhook capability | Automation owner | active owner, manage-own, target use, session create | -| Sentry | Verified Sentry webhook | Automation owner | active owner, manage-own, target use, session create | -| GitHub event | Verified GitHub service actor | Canonical GitHub actor | service ceiling; active actor with session create and target use; active owner with manage-own | -| Slack event | Verified Slack service actor | Canonical Slack actor | service ceiling; active actor with session create and target use; active owner with manage-own | -| Linear event | Verified Linear service actor | Canonical Linear actor | service ceiling; active actor with session create and target use; active owner with manage-own | - -The resulting session is owned by and attributed to the named canonical execution principal. The -initiator, service, and automation owner are recorded separately in invocation/audit metadata. Skill -profiles and user-linked credentials come from the execution principal; installation-wide secrets -and provider accounts remain selected by the automation's current allowed configuration. A manual -trigger never runs as another user's stored identity. Loss of any conjunctive authority marks the -invocation `skipped_authorization` without launching a session. Repeated scheduled or webhook -authorization failures pause the automation after the existing failure threshold and notify -administrators. Provider-account and secret resolution is repeated under the current execution -policy. - -New automations require an active canonical owner. Historical automations with missing or unresolved -owners are disabled during migration and require explicit reassignment by an Administrator or Owner -before execution. - -## Route Enforcement - -### Route metadata - -Authentication policy remains responsible for proving principal kind. Every route declaration also -contains required authorization metadata. Static permission routes declare the permission beside the -method and pattern: - -```ts -authorization: requirePermission("environments.manage"); -``` - -Session routes identify the operation applied to the already-matched path parameter. Conjunctive -policies list every requirement explicitly: - -```ts -authorization: requireAll( - permissionRequirement("sessions.create"), - permissionRequirement("sessions.collaborate") -); -``` - -The router executes declared permission, session-operation, and automation checks before handlers. -Request admission uses current authorization; a concurrent role change does not retroactively revoke -an admitted HTTP request. Personal active-user routes, active global routes, public routes, and -service-only callbacks each use an explicit policy kind; narrow internal callbacks name their exact -service. `router.policy.test.ts` rejects missing metadata, duplicate method/pattern pairs, -incompatible authentication/authorization combinations, and session requirements that reference -absent match groups. - -### Exemptions - -Only these ingress/authentication classes bypass browser authentication: - -- public health; -- browser-auth protocol endpoints; -- externally authenticated webhook ingress; -- image-build capability callbacks; -- session-bound sandbox routes; -- narrow internal service callbacks. - -Each exemption names its alternate ingress mechanism in route metadata. Webhook authenticity permits -normalization/queueing only; every resulting automation or resource operation still applies the -execution-authority policy before side effects. `user-or-service` alone is never sufficient -authorization after this change. - -A generated route-to-policy inventory covers every session, child-session, attachment, media, diff, -pull-request, credential, automation, secret, settings, and callback endpoint. Sandbox child -operations remain parent-session-bound; human child operations use workspace session permissions. - -### Listing and filtering - -Authorization applies before list queries, with contextual automation ownership applied in SQL where -needed. - -- Every user with `sessions.read` receives the workspace session list. -- Creator and Mine filters use `sessions.user_id` as attribution, not access control. -- Automation lists use `manage.any/read` or creator ownership as appropriate. -- Resources requiring a missing read permission are omitted from catalogs and navigation. -- Repository/environment catalogs require read permission; use permission is separately checked when - launching or configuring an execution target. - -## API Contracts - -### Current user authorization - -`GET /me/authorization` returns: - -```json -{ - "userId": "canonical-id", - "suspendedAt": null, - "role": { "id": "role-id", "key": "member", "name": "Member" }, - "permissions": ["repositories.read", "sessions.create"] -} -``` - -This endpoint is available only to the current browser user. Responses are private and no-store. - -### Role administration - -| Method | Path | Permission | Purpose | -| ------ | ------------ | ---------------------- | ------------------------------------ | -| `GET` | `/roles` | `workspace.roles.read` | List roles, counts, and permissions. | -| `GET` | `/roles/:id` | `workspace.roles.read` | Read one role and permissions. | - -### Member administration - -| Method | Path | Permission | Purpose | -| ------ | ------------------------- | -------------------------------------- | ------------------------------------- | -| `GET` | `/members` | `workspace.members.read` | List canonical users and assignments. | -| `PUT` | `/members/:userId/role` | `workspace.members.manage` or transfer | Replace one assignment. | -| `PUT` | `/members/:userId/status` | `workspace.members.manage` | Suspend or restore access. | - -Owner assignment or removal requires `workspace.transfer_ownership`, including when the caller also -has member-management permission. Suspending, deleting, or merging an Owner also requires transfer -permission. Every role/status/delete/merge mutation uses guarded SQL that succeeds only if another -unsuspended Owner remains in the same D1 batch. User deletion is blocked by assignment -`ON DELETE RESTRICT`; the assignment can be removed only through this guarded membership service. -User merge requires an explicit surviving assignment, repoints canonical session creator -attribution, and preserves both immutable audit snapshots. - -Assignment and status updates apply the request-scoped authorization decision and preserve Owner -invariants in the same D1 batch as the mutation. Authorization changes do not retroactively revoke -an already admitted request. - -### Error contract - -Forbidden API responses use: - -```json -{ - "error": "Forbidden", - "code": "permission_required", - "permission": "environments.manage" -} -``` - -Other denials use codes such as `active_user_required` and `service_capability_required`. Responses -do not disclose another user's role. - -## Web Experience - -### Authorization state - -The app shell loads current authorization with the browser session. It distinguishes: - -- unauthenticated; -- authenticated but suspended/unassigned; -- authenticated and authorized; -- authorization service unavailable. - -Permission checks consume the stable `hasPermission` predicate from the current-user authorization -hook. They hide navigation that has no readable content and disable contextual controls when -explaining the missing capability is useful. Server-rendered session pages authorize before fetching -snapshots. - -### Members and roles - -A Workspace settings section contains: - -- Members: identity, provider links, status, role, last activity, and assignment actions. -- Roles: built-in/custom roles, assignment count, and categorized permission details. -- Audit log: actor, action, target, outcome, reason, and timestamp. - -The UI prevents removing the last unsuspended Owner and assigning Owner without transfer permission. -The API repeats every invariant. - -### Existing navigation - -- Settings tabs appear only when at least one permission makes them useful. -- New session requires `sessions.create` plus target `use` permission. -- All/Mine becomes All/My sessions; both are filters over the workspace-wide session list. -- Session controls reflect read, collaborate, lifecycle, delete, and sandbox-access permissions - independently. -- Analytics requires `analytics.read`. -- Automation create/manage actions are independent from automation read access. - -The browser never treats hidden controls or downloaded permissions as security enforcement. - -## Audit and Observability - -Durable audit events are required for: - -- user role assignment; -- access suspension/restoration; -- Owner assignment/removal; -- secret, provider-account, commit-signing, integration, SCM, MCP, and shared-skill mutations; -- allowed and denied member-management operations. - -Pure D1 mutations write the audit event in the same D1 batch. - -High-volume ordinary reads and successful session messages remain in structured request logs rather -than D1 audit storage. Every authorization denial logs principal kind, actor user ID when known, -permission, policy, resource type, opaque resource ID, reason code, request ID, and service name. -Secret values, OAuth credentials, prompt content, and signed tokens never enter audit metadata. - -Metrics include denial count by permission/reason/principal, unassigned active users, assignment -count by role, and authorization latency. - -## Role Changes and Revocation - -- HTTP requests load current assignment/status and apply changes immediately. -- Role permission edits take effect on the next authorization lookup. -- Browser WebSocket credentials are bound to the canonical user. Subscribe verifies current D1 - authorization and rejects missing or suspended users, missing role assignments, and unavailable - authorization storage. -- A successful subscribe asks the WebSocket manager to grant a five-minute wall-clock authorization - lease. The manager persists its expiry in `ws_client_mapping` and owns earliest-expiry scheduling - in the unified alarm. On expiry the browser clears its credential and reconnects through the - authorized HTTP token route. -- Alarm and hibernation restoration close every expired connection even when it is idle. Every - inbound event and outbound broadcast also rejects expired leases as defense in depth. A role - change therefore revokes live browser access within the five-minute wall-clock lease bound. -- Bot calls authorize on every signed HTTP request. Stale Slack/Linear issue mappings do not bypass - current policy. -- Suspending a user invalidates Better Auth sessions. -- Existing sandboxes continue running because their credentials represent the session runtime, not - the user. Users who lose lifecycle permission cannot reconnect or control them. - -## Migration and Compatibility - -The migration is additive and preserves current capability for every canonical user: - -1. Create role, permission, assignment, and audit tables. -2. Insert protected built-in role records; their permission sets remain code-owned. -3. Assign Administrator to every canonical user present in `users`, including identities originally - created through Slack, GitHub, or Linear. -4. Create the unconditional default-role trigger. Identity provisioning after this point assigns - Member. - -No route switches to enforcement until every existing canonical user has an Administrator assignment -and built-in role reconciliation succeeds. Administrators may continue using the application before -Owner bootstrap. After deployment, the intended Owner signs in once to create a canonical user and -assignment. An operator then dry-runs and executes the root CLI against that canonical ID. Sign-in -and bot identity creation never assign Owner. - -Deployment documentation will state that Administrator preserves the previous installation-wide -operational behavior, while Member becomes the default for newly admitted users. - -### Operator bootstrap - -Terraform exports the D1 database name but does not configure an Owner identity. The supported -sequence is deploy, have the intended Owner sign in once, obtain the canonical ID from the browser -session, run `npm run rbac:bootstrap-owner -- --database --user `, review the dry-run -preflight, rerun with `--execute`, and verify `/health` reports `rbac.ownerAssignment=present`. - -When an unsuspended Owner assignment exists, `/health` reports `rbac.ownerAssignment=present`; when -none exists, it reports `missing`. Administrators and Members can use their existing capabilities, -but no one can exercise Owner-only actions. - -## Failure Handling - -- D1 authorization lookup failure denies the request and returns `503 authorization_unavailable`; it - never falls back to broad authenticated access. -- Missing or unknown role permissions deny and emit a reconciliation error. -- Missing user assignment denies shared application routes but permits sign-out and own identity - discovery so an administrator can repair access. -- Audit-write failure aborts transactional D1 administration. -- Web authorization metadata failure renders an unavailable state rather than the unrestricted app. - -## Security Invariants - -1. Authentication never implies authorization. -2. Admission allowlists never imply a role beyond bootstrap/default assignment. -3. Unknown permissions, missing assignments, suspended users, and policy errors deny access. -4. Client-side permission checks are never authoritative. -5. Creator and participant attribution are not authorization checks. -6. A service cannot exceed its code-defined ceiling. -7. An actor-backed service cannot exceed the linked user's current permissions. -8. An actorless service can execute only exact service-only operations. -9. Sandbox credentials remain bound to one session and confer no workspace role. -10. Before bootstrap, no user can exercise Owner-only actions; after bootstrap, at least one - unsuspended Owner always exists. -11. Only an Owner can add or remove Owner assignments. -12. Role changes and privileged mutations produce durable, redacted audit events. -13. Session lists require workspace read permission before returning metadata. -14. Secret-management permission never makes stored secret values readable. -15. External provider authorization is additional evidence, not a replacement for application RBAC. - -## Testing Strategy - -### Shared - -- Permission registry uniqueness and stable serialization. -- Built-in role snapshots and persisted custom-role resolution. -- API schema rejection of malformed role responses and assignments. - -### Control-plane unit - -- Human permission allow/deny matrix for every built-in role. -- Custom role resolution, suspension, missing assignment, and unknown permission behavior. -- Workspace-wide session operation permissions for every built-in role. -- Service ceiling and actor intersection for every bot. -- Actorless exact-endpoint service permissions. -- Last-Owner, built-in-role, assignment, and transaction invariants. -- Concurrent Owner demotion/suspension/delete and user-merge conflicts. -- Stable `401`, `403`, `404`, and `503` behavior. -- Route policy completeness requiring authorization metadata or named exemption. - -### Control-plane integration - -- Multi-user tests proving permitted Members can read, collaborate, manage lifecycle, access the - sandbox, and delete across workspace sessions. -- Viewer can read but cannot prompt, launch, stop, delete, or access sandbox credentials. -- Administrator can operate installation-wide resources but cannot transfer Owner. -- Owner can assign roles without removing the last unsuspended Owner. -- Secret/settings/provider-account/skill/MCP/image routes enforce individual permissions. -- Session lists remain workspace-wide while creator and Mine filters preserve attribution semantics. -- Role changes are enforced when idle, active, hibernated, and multi-tab WebSocket authorization - leases expire. -- Suspended browser sessions and bot actors are denied. -- D1 failure fails closed and audit failure aborts protected mutations. -- Automation schedule, webhook, event, and manual triggers reauthorize the correct execution - principal after owner suspension, demotion, role edit, and target-access loss. -- Sentry, GitHub, Slack, and Linear trigger tests assert session owner, initiator audit fields, - owner guard, service ceiling, actor permission intersection, and credential/profile source. - -### Web - -- Navigation and controls for Owner, Administrator, Member, Viewer, custom, suspended, and - unavailable states. -- Direct URL access remains denied when navigation is hidden. -- Session server rendering does not fetch unauthorized snapshots. -- Workspace member controls enforce API invariants. -- Generic forbidden responses do not trigger sign-in flows. - -### Bots - -- Each service can call only its ceiling routes. -- Linked actor role is required for actor-backed launches and prompts. -- Unlinked, suspended, and underprivileged actors fail closed with user-safe provider responses. -- Existing GitHub collaborator, Slack webhook, and Linear organization checks remain enforced. -- External session mappings cannot bypass actor role or service ceiling checks. - -### Migration - -- Empty installation assigns Member to new identities and requires an explicit canonical-ID operator - bootstrap for the initial Owner. -- Existing installation assigns every pre-migration canonical user Administrator, including bot-only - identities, then requires the same explicit operator bootstrap. -- Every canonical user receives exactly one assignment. -- Built-in role reconciliation is idempotent and rejects incompatible registry drift. -- Exact migration SQL executes under workerd/D1, including indexes and constraints. -- Better Auth or bot identity creation followed by assignment failure cannot enter business routes - and retries Member assignment idempotently. -- Owner bootstrap requires an existing unsuspended canonical user with exactly one assignment and - refuses another unsuspended Owner. -- CLI bootstrap is atomic and idempotent, writes exactly one redacted operator audit event on a - ready transition, and writes nothing when the target is already the current Owner. - -## Alternatives Considered - -### Role column on `users` - -Rejected because it cannot represent custom role metadata and permission composition without -hard-coding authorization throughout handlers. - -### Multiple roles per user - -Rejected for the initial system because role union and future deny semantics add complexity without -a current user requirement. One assignment directly matches user-level role configuration. - -### Per-repository and per-environment grants - -Deferred because current deployment identity and repository discovery are installation-wide. Adding -resource grants would require group semantics, environment membership rules, bot grant mapping, and -SCM synchronization decisions not resolved by current product behavior. - -### Encode permissions in browser sessions - -Rejected because role changes would remain stale for the Better Auth session lifetime and backend -handlers would still need authoritative policy state. - -### Use Session Durable Object participant roles as application RBAC - -Rejected because those roles exist only inside one session, are auto-created by current workflows, -and cannot govern installation settings or repository/environment actions. - -### External policy engine - -Rejected because the initial policy consists of a small fixed permission registry plus contextual -automation ownership. D1 and typed control-plane policy keep the trust boundary and operational -footprint within the existing architecture. - -## Open Product Decisions - -The design chooses defaults for implementation, but product confirmation is required before -enforcement: - -1. Session operations are workspace-wide when granted by the user's role. -2. New canonical users default to Member after the RBAC migration boundary. -3. Administrator receives all operational permissions except ownership transfer. -4. Persisted custom roles cannot receive ownership transfer. -5. Repository and environment access remains installation-wide rather than user-granted. -6. Existing users are promoted to Administrator to preserve current access. -7. Executing sandboxes continue after their creator is suspended or demoted. -8. Authorization audit events are retained under the deployment's existing D1 retention policy. -9. Scheduled/webhook automations stop launching when their owner loses current execution authority. -10. Session creator and participant identities are attribution, not authorization. -11. Five minutes is a strict wall-clock browser WebSocket revocation bound, including idle sockets. diff --git a/public/docs/internal/2026-08-28-rbac-research.md b/public/docs/internal/2026-08-28-rbac-research.md deleted file mode 100644 index 8384e78bb..000000000 --- a/public/docs/internal/2026-08-28-rbac-research.md +++ /dev/null @@ -1,386 +0,0 @@ -# Research: Role-Based Access Control - -**Date:** 2026-08-28 - -**Status:** Superseded research snapshot - -**Scope:** Current identity, authentication, authorization, resources, actions, storage, user -workflows, service integrations, and operational trust boundaries relevant to application RBAC. - -The implemented model is documented in [Role-Based Access Control](./2026-08-28-rbac-design.md). - -This document is intentionally research-only. It does not include recommendations, implementation -plans, proposed code/API/schema changes, task breakdowns, estimates, or rollout steps. - -## Summary - -Open-Inspect authenticates human users, first-party services, and session-bound sandboxes, but it -does not have an application role, workspace membership, permission, grant, or administrator model. -The deployment is explicitly single-tenant: admission policy determines who may sign in, and an -admitted human generally shares installation-wide access to repositories, sessions, environments, -secrets, settings, provider accounts, automations, skills, MCP servers, image controls, and -analytics. - -Human identity is canonicalized across GitHub, Google, Slack, and Linear. First-party bots sign -requests as distinct services and may assert actors in their own provider namespace. Sandboxes use -credentials bound to one session. These principal distinctions constrain authentication channels, -but most route policies do not distinguish capabilities among admitted humans or among signed bot -services. - -Sessions contain `owner` and `member` participants, but those roles are not a general authorization -boundary. Session creator fields primarily support attribution and filtering. Existing visibility -logic deliberately returns any session in the installation, and authenticated users or services can -join, prompt, inspect, stop, or mutate many sessions without an owner check. - -The application has three broad resource scopes today: per-user preferences, session-scoped runtime -state, and installation-wide operational resources. Repository and environment resources do not have -application membership or grant records. External source-control permissions are consulted in some -GitHub bot trigger paths, but ordinary web and service access uses the deployment's SCM App or token -authority. - -## Research Questions - -1. Which identities and authentication channels exist today? -2. Which application resources and actions would intersect with authorization decisions? -3. Which resources are personal, session-scoped, repository/environment-scoped, or - installation-wide? -4. Where are authorization decisions currently made, and what do they enforce? -5. How do Slack, GitHub, Linear, sandboxes, and deployment operators cross trust boundaries? -6. Which current fields represent attribution rather than ownership or access? -7. Which gaps and unresolved product semantics affect an RBAC design? - -## Current Behavior - -### Human identity and admission - -- Canonical users are stored in D1 `users`; provider identities are stored in `user_identities` and - linked by canonical user ID. -- Browser sign-in supports GitHub and Google through Better Auth. Browser requests reach the control - plane through a signed `service:web` channel and a valid browser session cookie. -- Admission supports GitHub login, email, email domain, and GitHub organization allowlists, plus an - explicit unsafe allow-all mode. Admission only controls sign-in eligibility. -- The browser session contract exposes user ID, name, email, and image. It has no role, permission, - membership, workspace, or resource-grant data. -- Canonical user IDs currently scope keyboard shortcuts, managed-skill profiles, session read state, - temporary provider-account authorization transactions, and the session-list `Mine` filter. - -### Request principals and route policies - -The control plane resolves every authenticated request to one of: - -| Principal | Identity boundary | Current use | -| ------------------- | ----------------------------------------- | -------------------------------------------------- | -| Human user | Canonical user ID | Browser-originated application requests | -| First-party service | Service name plus optional asserted actor | Web, Slack, GitHub, and Linear Workers | -| Sandbox | Session ID | Session runtime callbacks and credential brokerage | - -Route authentication distinguishes public, handler-authenticated, web-service, human-user, -user-or-service, sandbox, and sandbox-fallback requests. It does not express application actions, -resource scopes, user roles, or grants. Human-only routes exclude bots but admit every authenticated -human. Most `user-or-service` routes admit every signed first-party service, not a named subset. - -### Session visibility and participation - -- Session creation stores a canonical creator in the D1 session index and creates a Durable Object - participant with role `owner`. -- Other identities are added as `member` participants when they request a WebSocket token or send a - prompt. -- `SessionIndexStore.getVisibleForUser()` deliberately ignores the supplied user ID and returns any - existing session. Its source comment names this the single-tenant visibility boundary. -- Session lists are global unless `createdBy=me` is supplied as an explicit filter. -- Session title, archive, and unarchive handlers require participation, but do not distinguish - `owner` from `member`. Other lifecycle and runtime routes do not consistently require existing - participation. -- An authenticated user or asserted service actor can request a WebSocket token for a session and be - added as a member. Prompt submission follows the same auto-membership pattern. -- Deletion, stop, event, artifact, media, attachment, participant, pull-request, and other session - operations generally rely on route authentication and a supplied session ID rather than creator or - participant ownership. -- Sandbox credentials are verified against the Session Durable Object and cannot authenticate to a - different session. Child-sandbox fallbacks are also bound to their parent session. - -### Installation-wide resources - -The following resources are shared across admitted users in the current deployment model: - -| Resource | Read actions | Mutation or execution actions | -| ------------------------ | ------------------------------------------- | ------------------------------------------------------------ | -| Repository catalog | List repositories, branches, metadata | Use as session/environment/automation targets | -| Global secrets | List key metadata | Create/update/delete values | -| Repository secrets | List key metadata | Create/update/delete values | -| Environments | List/view | Create/update/delete; manage repositories and branches | -| Environment secrets | List key metadata | Create/update/delete/import values | -| Integration settings | View global/repository/environment settings | Enable, update, override, reset | -| SCM and sandbox settings | View configuration | Update/reset defaults and overrides | -| Model preferences | View enabled models | Change installation-wide model visibility | -| Provider accounts | List/status | Connect, reconnect, rename, verify, enable, disable, default | -| Automations | List/view runs | Create, edit, trigger, pause, resume, delete, rotate key | -| Managed shared skills | List/view | Import, edit, assign, reimport, delete | -| MCP servers | List/view | Create, edit, delete commands, headers, and environment | -| Image builds | View status/feed | Toggle prebuilds, trigger builds | -| Commit signing | View metadata | Configure/update/delete signing material | -| Analytics | View installation aggregates | No primary mutation workflow | - -Environments have no owner, member, team, role, or ACL columns. Repository access is based on the -deployment's SCM App installation or configured token. Generic settings and secret stores are not -keyed by user. Provider-account creator/updater IDs and automation creator fields record attribution -but do not restrict later access. - -### Personal and local resources - -- Keyboard shortcut preferences are stored by canonical user ID. -- Managed-skill profiles are associated with a canonical user, while the shared skill catalog is - installation-wide. -- Session read states are stored by `(user_id, session_id)` but rely on the broad session visibility - boundary. -- Provider-account device-authorization transactions are user-scoped while in progress; completed - provider accounts are installation-wide. -- Appearance and syntax preferences are browser-local. -- Slack and Linear bot preferences are provider-user-scoped in their Workers' KV stores. - -### Web application behavior - -- `AppAuthBoundary` gates the application shell on authentication state only. -- The sidebar exposes new session, all/mine sessions, settings, automations, analytics, and archived - sessions to every authenticated user. -- Settings navigation is identical for all authenticated users except for deployment-capability - checks such as repository-image support. -- Session controls react to lifecycle, connection, and loading state, not participant role. -- No client condition was found for an administrator flag, role, permission list, repository grant, - environment membership, session owner role, or creator equality. -- The client does not currently represent an authenticated-but-forbidden state distinct from sign-in - admission denial, aside from generic API errors. - -## Relevant Workflows - -### Browser request - -1. GitHub or Google OAuth establishes a Better Auth browser session. -2. The Next.js server signs the control-plane request as `service:web` and forwards the browser - cookie. -3. The control plane verifies both channel and browser identity and creates a user principal. -4. The route policy checks principal kind and SCM compatibility. -5. The handler reads or mutates the requested resource; most handlers have no additional user-level - access check. - -### Bot-created session - -1. A bot verifies an external Slack, GitHub, or Linear webhook. -2. The bot signs a control-plane request with its per-service secret and may assert the external - actor in its namespace. -3. The control plane verifies the service and actor namespace, resolves or creates a canonical user, - and derives session identity from the principal. -4. Session creation requires an actor-backed participant. Existing-session prompts may be actorless - and are then attributed to `anonymous`. -5. The selected repository or environment is resolved using deployment-wide catalogs and - credentials. GitHub trigger flows additionally enforce configured allowlists or GitHub - write-level collaborator permissions; Slack and Linear do not perform equivalent SCM-user checks. - -### Session collaboration - -1. A browser or bot addresses a session by ID. -2. A WebSocket-token or prompt request can create a `member` participant automatically. -3. The Session Durable Object stores participants, messages, artifacts, diffs, repositories, sandbox - state, and credentials. -4. Participant role is returned in shared session types, but the web does not consume it as an - authorization signal. - -### Sandbox runtime - -1. The control plane creates and hashes a per-session sandbox token. -2. The token and session configuration are injected into the sandbox. -3. Sandbox requests are authenticated against the session ID in the route. -4. Session-bound routes broker SCM credentials, provider access, commit signing, skills, - attachments, and runtime events. -5. The sandbox is not represented as a human role and cannot authenticate outside its bound session - through the sandbox credential. - -### Deployment and data plane - -1. GitHub Actions and Terraform provision Cloudflare, D1, R2, Workers, service secrets, and Modal. -2. Deployment operators hold authority outside the application's principal model through source - control, GitHub environments, Cloudflare, Terraform state, Modal, and SCM App installation - access. -3. The control plane authenticates to Modal with a deployment-wide HMAC secret. -4. Modal trusts possession of that secret for authenticated endpoints and does not receive the - initiating application user, role, or resource grants. - -## Existing Patterns - -### Central authentication composition - -The router attaches a verified principal before authenticated handlers run. Route definitions carry -typed authentication policy, and policy-completeness tests assert that every route declares one. - -### Canonical cross-provider identity - -Browser and bot identities converge on a canonical D1 user while retaining provider identity and -participant identity. Body-supplied identity and credential fields are rejected for -identity-sensitive routes. - -### Session-bound capabilities - -Sandbox tokens, image-build callback tokens, and browser participant WebSocket tokens are scoped to -specific runtime resources rather than functioning as installation-wide human credentials. - -### Provider and scope registries - -Repositories use shared identity helpers, environments have opaque IDs and ordered repository -membership, image builds use explicit repository/environment scope kinds, and integration settings -already resolve global, repository, and environment levels. - -### Attribution without authorization - -Sessions, automations, provider accounts, skills, and logs record creators or actors. Existing code -and design documents explicitly distinguish these fields from ownership checks. - -### Denial and audit behavior - -Authentication failures use `401`; principal-kind failures use `403`. Some sensitive workflows, -including managed skills and Slack notification, emit structured audit logs. There is no complete, -durable application authorization audit ledger. - -## Constraints and Invariants - -- TypeScript and Python use milliseconds and seconds respectively for durations. -- Shared contracts are consumed by control plane, web, and bot packages and are built first. -- D1 is the installation-wide relational store; each Session Durable Object has separate SQLite - state and is not directly joinable with D1 during an in-object operation. -- Route authentication happens before handler execution; handler-authenticated webhooks apply their - own provider or capability checks. -- Browser requests must retain both a signed web-service channel and a valid browser session. -- Bot actors can only be asserted by their owning first-party service namespace. -- Caller-supplied identity fields are rejected where verified principal identity is required. -- Sandbox credentials remain session-bound and session provider-auth choices are immutable after - creation. -- Repository owners may contain nested path segments; repository identity helpers split on the last - slash and preserve the complete owner. -- Environment sessions snapshot repository membership; later environment changes do not alter - existing sessions. -- Secrets are encrypted at rest and values are not returned by list operations, but authorization to - manage their ciphertext and metadata is installation-wide. -- The Modal API receives a deployment credential, not end-user identity; application authorization - currently terminates at the control plane. -- Existing admitted users have broad access under documented single-tenant semantics. - -## Known Gaps and Risks - -- No role, membership, grant, group, workspace, or administrator records exist in D1. -- No authorization action vocabulary or resource-scope vocabulary exists in shared contracts. -- Route policies conflate authentication channel, principal kind, SCM support, and broad route - access; handlers apply resource checks inconsistently. -- `GITHUB_USER_OR_SERVICE_ROUTE` and similar policies often admit all signed services despite their - names. -- Session `owner/member` roles do not define owner-exclusive actions and do not govern most access. -- Session creator, provider-account creator, automation creator, and updater fields can be mistaken - for authorization ownership despite current attribution-only behavior. -- The repository catalog reflects installation authority rather than authenticated-user grants. -- A repository can belong to multiple environments, and environments can contain multiple - repositories; current data has no rules for combining access at those boundaries. -- Bots differ in external authorization evidence. GitHub has repository permission checks in trigger - flows, while Slack and Linear rely primarily on webhook authenticity, configured mappings, and - deployment catalogs. -- Service credentials provide broad route-family capabilities and are not generally constrained by - actor, creator, repository, or session. -- The web exposes navigation and controls before knowing whether an action could be forbidden. -- There is no complete durable record of allow/deny decisions, policy changes, role assignment, or - access revocation. -- Existing tests primarily distinguish authenticated from unauthenticated requests, not multiple - human capability levels or cross-user denial. -- Long-lived sessions, WebSockets, bot mappings, and sandboxes can outlast changes to human access; - current code has no access-revocation lifecycle because access grants do not exist. -- External operator authority is outside the application and cannot be represented by current - principals. - -## Open Questions - -1. Does one Open-Inspect installation correspond permanently to one workspace, or can an - installation contain multiple independently administered organizations? -2. Are application roles intended to be fixed built-in roles, configurable custom roles, or both? -3. Which role bootstraps the first deployment administrator, and how is loss of all administrators - recovered? -4. Are repository permissions inherited solely from an application role, assigned per user/group, - synchronized from SCM, or combined from those sources? -5. Are environments independent authorization resources or derived from access to all, any, or the - primary member repository? -6. Are sessions private to creators by default, visible to users with target access, or visible to - the whole workspace? -7. Which session actions differ among creator, participant owner, participant member, repository - maintainer, and workspace administrator? -8. Does adding a participant grant access, or merely record collaboration after another policy has - admitted access? -9. Do automation runs and child sessions inherit access from the automation owner, triggering actor, - target resource, parent session, or a service identity? -10. Which first-party services may read or mutate installation settings, secrets, provider accounts, - and arbitrary sessions? -11. Do bots act with service-owned capabilities, the asserted human actor's capabilities, or an - intersection of both under the intended product semantics? -12. How are actors without a linked canonical user handled when authorization requires user-level - grants? -13. Is viewing secret key metadata distinct from writing or deleting secret values? -14. Are analytics, user directories, audit records, and usage/cost data separate administrative - capabilities? -15. Which role and grant changes must revoke active WebSockets, bot thread mappings, sandbox access, - or in-flight provider authorization transactions? -16. Which authorization changes require historical audit retention, and for how long? -17. Must existing admitted users preserve their current broad access when role records first appear? -18. Are deployment operators expected to be application administrators, or are these intentionally - separate authority domains? - -## Evidence - -- `packages/control-plane/src/auth/principal.ts`: defines user, service, and sandbox principals and - service actor-namespace rights. -- `packages/control-plane/src/auth/authenticate.ts`: composes signed web-service and browser-session - authentication. -- `packages/control-plane/src/auth/identity-enforcement.ts`: derives actor identity and rejects - caller-supplied identity fields. -- `packages/control-plane/src/auth/user/admission-policy.ts`: defines sign-in admission rules. -- `packages/control-plane/src/db/user-store.ts`: canonicalizes provider identities into users. -- `packages/control-plane/src/routes/shared.ts`: defines route authentication and SCM policies. -- `packages/control-plane/src/router.ts`: attaches principals and enforces principal-kind policies. -- `packages/control-plane/src/db/session-index.ts`: implements installation-wide session visibility. -- `packages/control-plane/src/routes/session-index.ts`: lists and deletes sessions and stores - per-user read state. -- `packages/control-plane/src/routes/session-runtime-proxy.ts`: exposes session runtime actions. -- `packages/control-plane/src/routes/session-ws-token.ts`: mints participant WebSocket credentials. -- `packages/control-plane/src/routes/session-prompt.ts`: derives prompt authors and allows automatic - session participation. -- `packages/control-plane/src/session/schema.ts`: stores Session Durable Object participants and - runtime state. -- `packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts`: checks - participation for selected lifecycle mutations. -- `packages/shared/src/types/sessions.ts`: defines `owner/member` participant roles. -- `packages/web/src/lib/browser-auth-session-contract.ts`: exposes browser user identity without - authorization data. -- `packages/web/src/components/app-auth-boundary.tsx`: gates the application on authentication. -- `packages/web/src/components/session-sidebar.tsx`: exposes shared navigation and All/Mine filters. -- `packages/web/src/components/settings/settings-nav.tsx`: exposes installation settings without - user-role filtering. -- `packages/control-plane/src/routes/repos.ts`: lists repositories using deployment SCM authority. -- `packages/control-plane/src/routes/environments.ts`: exposes installation-wide environment CRUD. -- `packages/control-plane/src/routes/secrets.ts`: exposes global and repository secret management. -- `packages/control-plane/src/routes/environment-secrets.ts`: exposes environment secret management. -- `packages/control-plane/src/routes/integration-settings.ts`: manages global, repository, and - environment settings. -- `packages/control-plane/src/routes/model-provider-accounts.ts`: manages installation-wide provider - accounts with human-only authentication. -- `packages/control-plane/src/routes/automations.ts`: exposes shared automation lifecycle actions. -- `packages/control-plane/src/routes/skills.ts`: separates shared skill administration from per-user - profiles. -- `packages/control-plane/src/routes/mcp-servers.ts`: exposes shared MCP server management. -- `packages/control-plane/src/routes/analytics.ts`: exposes installation-wide analytics. -- `terraform/d1/migrations/0019_create_users.sql`: creates canonical users and attribution columns. -- `terraform/d1/migrations/0033_environments.sql`: creates environments without ownership or grants. -- `terraform/d1/migrations/0055_session_read_states.sql`: creates per-user session read state. -- `docs/HOW_IT_WORKS.md`: documents the single-tenant security and repository-access model. -- `provider-accounts.md`: explicitly treats creator/updater fields as audit metadata and provider - accounts as installation-wide. -- `packages/slack-bot/src/sessions/control-plane-client.ts`: sends signed Slack actor session calls. -- `packages/github-bot/src/handlers.ts`: applies GitHub trigger and sender authorization checks. -- `packages/linear-bot/src/webhook-handler.ts`: resolves Linear actors and session targets. -- `packages/control-plane/src/sandbox/client.ts`: authenticates deployment-wide control-plane calls - to Modal. -- `packages/control-plane/src/router.policy.test.ts`: checks route authentication policy coverage. -- `packages/control-plane/test/integration/ws-token-participants.test.ts`: verifies automatic member - creation. diff --git a/public/docs/internal/2026-08-30-session-access-research.md b/public/docs/internal/2026-08-30-session-access-research.md deleted file mode 100644 index 7249aca01..000000000 --- a/public/docs/internal/2026-08-30-session-access-research.md +++ /dev/null @@ -1,407 +0,0 @@ -# Research: Session Access and Contribution - -**Date:** 2026-08-30 **Status:** Superseded current-state snapshot **Scope:** Session permission, -relationship, participant, listing, and WebSocket behavior before workspace-wide session -authorization was adopted. - -This document is intentionally research-only. It does not include recommendations, implementation -plans, proposed code/API/schema changes, task breakdowns, estimates, or rollout steps. - -The accepted replacement is -[Workspace-Wide Session Authorization](./2026-08-30-workspace-wide-session-authorization-design.md). - -## Summary - -The current system does not generally require a user to be a session creator or participant before -they can read or contribute to a session. Built-in Members receive `sessions.read.any` and -`sessions.collaborate.any`; Viewers receive `sessions.read.any`. These `any` permissions bypass the -`session_access` relationship table entirely. An unrelated Member can therefore list, read, prompt, -upload collaborative artifacts, and request a WebSocket token for any workspace session. - -`session_access` remains active in narrower workflows. It gates Member lifecycle and sandbox access, -requires creator status for Member deletion and participant management, supports custom roles that -hold only `.own` permissions, filters own-scoped lists, and constrains every actor-backed bot call -because service actors are forced to `own` scope. WebSocket subscription also consults it when the -user's collaboration permission resolves to `.own`. - -The system also has a separate Session Durable Object `participants` table. It stores session-local -identity, SCM metadata, WebSocket tokens, presence identity, and an `owner` or `member` role. It is -not the authority used by `requireSession`, but title, archive, and unarchive still require the -caller to exist in that table. D1 relationships and Durable Object participants can therefore -diverge and have different effects. - -The resulting complexity represents several different concerns under similar terminology rather than -one uniform contribution boundary. - -## Research Questions - -1. Does session access currently restrict who can read or contribute to a session? -2. Which operations still depend on creator or participant relationships? -3. What does `requireSession` enforce for humans, services, and sandboxes? -4. How do D1 `session_access` and Durable Object participants differ? -5. Which current behaviors and documents are inconsistent or ambiguous? - -## Current Behavior - -### Built-in role behavior - -The built-in role registry gives Members these session permissions: - -- `sessions.read.any` -- `sessions.collaborate.any` -- `sessions.lifecycle.own` -- `sessions.participants.manage.own` -- `sessions.delete.own` -- `sessions.sandbox_access.own` - -Viewers receive `sessions.read.any` and no contribution or lifecycle permission. Administrators and -Owners receive the `any` form of every session operation. - -`resolveScopedPermission()` selects `any` before `own`. The router does not query a session -relationship after resolving `any`. - -Consequences for a built-in Member: - -| Operation | Existing relationship required? | Current basis | -| ------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------- | -| List sessions | No | `sessions.read.any` | -| Read session state, messages, artifacts, media, diffs, and children | No | `sessions.read.any` | -| Submit an HTTP prompt | No | `sessions.collaborate.any` | -| Request a WebSocket token | No | `sessions.collaborate.any` | -| Upload attachments, media, or diffs | No | `sessions.collaborate.any` | -| Create a pull request or child session | No prior relationship | `sessions.collaborate.any`, plus operation-specific requirements | -| Stop, rename, archive, unarchive, refresh, or retry | Yes | `sessions.lifecycle.own` | -| Obtain sandbox credentials | Yes | `sessions.sandbox_access.own` | -| Delete a session | Creator only | `sessions.delete.own` | -| Manage participants | Creator only | `sessions.participants.manage.own` | - -An Administrator or Owner bypasses these relationship requirements through the corresponding `*.any` -permission at the router layer. - -### Operation-to-relationship mapping - -`session-authorization-policy.ts` maps each operation to both a permission stem and an own-scope -relationship: - -| Operation | Permission stem | Relationship under `.own` | -| ---------------------- | ------------------------------ | ------------------------- | -| Read | `sessions.read` | Creator or participant | -| Collaborate | `sessions.collaborate` | Creator or participant | -| Lifecycle | `sessions.lifecycle` | Creator or participant | -| Participant management | `sessions.participants.manage` | Creator | -| Sandbox access | `sessions.sandbox_access` | Creator or participant | -| Delete | `sessions.delete` | Creator | - -The term `own` therefore has two meanings in current policy. For four operations it means any access -relationship; for deletion and participant management it means creator. - -### `requireSession` - -`requireSession(operation, sessionIdParam)` creates an active-user route policy with one session -requirement. At request admission, the router: - -1. Loads the effective authorization for the human user or represented service actor. -2. Rejects suspended users and missing role assignments. -3. Resolves the operation's `any` or `own` permission. -4. Applies the signed service's capability ceiling. -5. Forces signed service actors to `own` scope. -6. Queries `session_access` only when the resulting scope is `own`. - -Relationship failures return `session_access_required` or `creator_required` with HTTP 403. -Unexpected authorization storage failures return `authorization_unavailable` with HTTP 503. - -For sandbox-fallback routes, `requireSession` describes the user/service path. A verified sandbox -principal does not have a workspace user authorization and bypasses these RBAC requirements. Its -authority comes from the sandbox token being bound to the route's session ID. - -### D1 `session_access` - -Migration 0071 defines one canonical relationship per session and workspace user: - -```sql -CREATE TABLE session_access ( - session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - relation TEXT NOT NULL CHECK (relation IN ('creator', 'participant')), - PRIMARY KEY (session_id, user_id) -); -``` - -The table contains no activity state, timestamps, invitation source, participant identifier, or -WebSocket state. - -Creator rows are inserted with the D1 session index. Migration 0071 backfills canonical historical -creators. Participant rows are inserted after: - -- successful public WebSocket-token issuance; -- successful public participant addition. - -Participant activation uses `ON CONFLICT DO NOTHING`, so an existing creator row is never downgraded -to participant. - -There is no production participant-removal route or D1 deactivation helper. Relationship deletion -currently occurs through session/user cascade, user merge, test setup, or direct database activity. - -### Session Durable Object participants - -The Session Durable Object has a separate `participants` table containing: - -- a session-local participant ID; -- a provider/session-local `user_id`; -- an optional canonical D1 `canonical_user_id`; -- SCM identity and credentials; -- `owner` or `member` role; -- WebSocket token hash and issuance time; -- join time. - -Session initialization creates an owner participant. WebSocket-token issuance creates or enriches a -member participant. API prompt enqueue also creates a missing participant. - -The DO `owner` or `member` value is not read by `requireSession`. Canonical creator authority comes -from D1 `session_access.relation = 'creator'`. The DO role is returned in participant responses and -persists as session-local state. - -Title, archive, and unarchive differ from other lifecycle routes: after router authorization, their -DO handlers also require the acting identity to exist in the local participants table. Stop, pull -request refresh, diff retry, and child cancellation do not share that second participant-existence -check. - -### Contribution paths - -HTTP prompt admission uses `requireSession("collaborate")`. For a built-in Member this resolves to -`collaborate.any`, so no relationship is required. The DO creates a participant when the prompt -author is not already present, but this prompt path does not create a D1 `session_access` row. - -WebSocket-token issuance also uses `collaborate`. A successful token response creates both a DO -participant and a D1 participant relationship. This means the common browser join flow establishes -the relationship after open collaboration has already authorized the join. - -Once a browser WebSocket subscribes successfully, prompt, cancel, stop, history, typing, and -presence messages use the authenticated client and its authorization lease. Individual WebSocket -commands do not independently resolve read, collaborate, or lifecycle permissions. - -### WebSocket authorization - -The initial WebSocket upgrade verifies only that the session exists. The socket remains -unauthenticated until it sends a subscription token. - -Subscription verifies: - -- the token hash maps to a DO participant; -- the participant has a canonical user ID; -- the canonical user is active and assigned; -- current `sessions.collaborate` permission; -- D1 access when collaboration scope is `.own`; -- the 24-hour token lifetime. - -A successful subscription receives a five-minute authorization lease. During that lease, permission -and relationship changes are not continuously queried. Expiry closes the socket and a later -subscription evaluates current authorization again. - -For the built-in Member's `collaborate.any`, subscription does not require the D1 relationship. For -custom roles with only `collaborate.own`, removing the relationship causes a later subscription to -fail. - -### Lists and displayed capabilities - -Session list and inbox SQL use `sessionAccessPredicate()` only when read scope is `own`. For scope -`any`, the predicate is `1 = 1`. - -Because Member and Viewer use `read.any`, their ordinary lists are workspace-wide. The `Mine` filter -is separate: it filters `sessions.user_id`, which is creator attribution rather than an -authorization relationship. - -At the time of this research, lists also computed `canManageLifecycle` from the caller's lifecycle -scope and relationship. The workspace-wide authorization implementation later removed that response -field; the web client now derives lifecycle-control visibility from current-user permissions, while -lifecycle endpoints perform their own request admission. - -### Services and bots - -Signed services use the represented canonical actor's role, a hard-coded service capability ceiling, -and a forced `own` session scope. A bot actor therefore needs a D1 creator or participant -relationship even when that actor's built-in Member role contains `read.any` and `collaborate.any`. - -This produces a contribution boundary for bot actors that does not exist for browser Members. An -unrelated Slack actor is denied when prompting another actor's session with -`session_access_required`. - -No session route currently declares an actorless service grant. Several bot call sites issue -actorless session requests, including Slack attachment/media operations and Linear stop/event -operations. Central route admission rejects such requests with `service_actor_required` before -session relationship evaluation. - -### Child sessions - -User/service child creation requires `sessions.create` and collaboration on the parent. A parent -sandbox token can create a child through the sandbox capability path without user RBAC. - -The child creator is the parent session's active prompt author. Parent access does not automatically -create child access for a different parent creator. User/service child read and cancellation are -authorized against the child, while the parent sandbox path authenticates against the parent and -then checks parent-child lineage in the handler. - -## Relevant Workflows - -### Browser Member joins an unrelated session - -1. Session list is visible through `sessions.read.any`. -2. Session read is admitted without `session_access`. -3. WebSocket-token request is admitted through `sessions.collaborate.any`. -4. The DO creates or updates a participant and rotates its token. -5. The control plane inserts D1 participant access. -6. Subscription rechecks collaboration and grants a five-minute lease. -7. The participant relationship now satisfies Member lifecycle-own and sandbox-access-own. - -### HTTP prompt without WebSocket token - -1. Prompt request is admitted through `sessions.collaborate.any` for a Member. -2. The DO creates a missing participant and enqueues the prompt. -3. No D1 participant relationship is created by this path. -4. Later lifecycle-own or sandbox-access-own checks still depend on another path having created D1 - access. - -### Actor-backed bot contribution - -1. The service signature identifies the service and represented actor. -2. The actor's current workspace authorization is loaded. -3. The service ceiling is applied. -4. Session scope is forced to `own`. -5. The actor must already have creator or participant D1 access. - -### Administrator lifecycle request without joining - -1. `sessions.lifecycle.any` passes router admission without D1 access. -2. Stop, refresh, and retry can proceed without a DO participant check. -3. Title, archive, and unarchive query the DO participant table and return 403 when the identity is - absent. - -## Existing Patterns - -- Workspace permissions and session relationships are evaluated in the control-plane router. -- The D1 relationship projection uses canonical workspace user IDs. -- The Session DO participant table owns session-local attribution, SCM metadata, tokens, and - connection identity. -- Open collaboration is expressed by built-in `*.any` permissions rather than an exception inside - relationship code. -- Service actors are intentionally narrowed to `own` regardless of their human role's `any` grant. -- Sandbox principals use possession of a session-bound capability instead of workspace RBAC. -- WebSocket authorization is evaluated at subscription and represented by a bounded lease. -- Session list authorization and lifecycle capability are calculated in SQL before results are - returned. - -## Constraints and Invariants - -- One canonical user has at most one D1 relationship per session. -- Creator access is not replaced by participant activation. -- Own-scoped deletion and participant management require creator relation. -- Other own-scoped operations accept creator or participant relation. -- Any-scoped operations do not consult `session_access`. -- Actor-backed services cannot use any-scoped session access. -- A sandbox token is valid only for its bound session route. -- Successful WebSocket subscription requires a canonical user identity. -- WebSocket authorization is bounded by a five-minute lease and token use by a 24-hour lifetime. -- D1 and Session DO writes do not share a cross-store transaction. -- User merge preserves the strongest D1 relationship when creator and participant rows collide. - -## Known Gaps and Risks - -### Relationship and participant divergence - -The two stores have different writers and no reconciliation workflow: - -- API prompt creates a DO participant without D1 access. -- DO success followed by D1 activation failure leaves a DO participant without D1 access. -- D1 user merge rewrites access but does not update existing DO canonical participant identities. -- There is no participant-removal flow spanning D1, DO tokens, presence, or existing sockets. -- DO `owner/member` and D1 `creator/participant` can disagree. - -### Inconsistent lifecycle enforcement - -Title, archive, and unarchive require local DO participant existence after router authorization. -Other lifecycle endpoints do not. This makes `sessions.lifecycle.any` behavior dependent on the -specific endpoint and whether the caller previously joined the session. - -### Contribution does not uniformly establish access - -WebSocket-token contribution establishes D1 participant access; direct HTTP prompting does not. Both -can establish a DO participant. - -### Service-call mismatches - -Some bot call sites omit actors for routes whose central policy requires one. Package-local tests -mock the control plane and do not cover these calls through real central authorization. - -### Documentation drift - -The RBAC design includes mutually inconsistent statements about Member visibility. Its role matrix -describes open Member read/collaboration, while other sections describe Member lists as -creator/participant filtered. It also documents participant removal that is not implemented and -states that the DO has no local owner role even though that field remains in schema and runtime -behavior. - -### Test coverage boundaries - -Existing tests cover scoped permission resolution, relationship checks, list filtering, WebSocket -subscription, service actor isolation, creator-only deletion, and projection writes. No -comprehensive role-by-operation HTTP matrix or end-to-end test of active WebSocket authorization -changes across a lease boundary was found. - -## Open Questions - -1. Is `session_access` intended to represent durable membership, a capability projection, or only - the relationship input for `.own` permissions? -2. Is open Member contribution intended to establish membership, or is the relationship created by - WebSocket-token issuance incidental to the current browser workflow? -3. Is direct HTTP prompt participation intentionally excluded from D1 participant activation? -4. Are the DO participant checks on title, archive, and unarchive intentional authorization or - residual pre-RBAC behavior? -5. Does actor-backed service isolation intentionally differ from open browser Member collaboration? -6. Are DO `owner/member` roles still part of supported session semantics, or only retained state for - compatibility and presentation? -7. Was participant removal deliberately excluded from the current product surface? -8. Is parent-to-child access intentionally independent when the active prompt author differs from - the parent creator? -9. Are the RBAC design documents historical artifacts, living documentation, or a mixture of both? - -## Evidence - -- `packages/shared/src/rbac.ts`: built-in role permission sets and any-before-own scope resolution. -- `packages/control-plane/src/authorization/session-authorization-policy.ts`: - operation-to-permission and operation-to-relationship mapping. -- `packages/control-plane/src/routes/shared.ts`: `requireSession` route metadata construction. -- `packages/control-plane/src/router.ts`: active-user, service-ceiling, scoped-permission, and - relationship enforcement. -- `packages/control-plane/src/db/session-access.ts`: list predicate, exact relationship check, and - participant activation. -- `terraform/d1/migrations/0071_rbac_foundation.sql`: relationship schema, index, and creator - backfill. -- `packages/control-plane/src/db/session-index.ts`: creator insertion, own-scoped listing, and - lifecycle capability projection. -- `packages/control-plane/src/db/session-inbox-store.ts`: inbox visibility and lifecycle capability. -- `packages/control-plane/src/routes/session-ws-token.ts`: public token issuance and D1 participant - activation. -- `packages/control-plane/src/routes/session-prompt.ts`: collaboration admission and - principal-derived prompt identity. -- `packages/control-plane/src/session/message-queue.ts`: prompt-created DO participants. -- `packages/control-plane/src/session/schema.ts`: DO participant schema and owner/member role. -- `packages/control-plane/src/session/connection-authenticator.ts`: WebSocket token, canonical user, - authorization, and token-age checks. -- `packages/control-plane/src/session/websocket-manager.ts`: lease persistence, lookup, and expiry. -- `packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts`: residual DO - participant checks for title/archive/unarchive. -- `packages/control-plane/src/authorization/service-permissions.ts`: bot service capability - ceilings. -- `packages/control-plane/test/integration/rbac-routes.test.ts`: open Member lists and creator-only - deletion. -- `packages/control-plane/test/integration/websocket-client.test.ts`: any/own collaboration, - relationship loss, suspension, and assignment failure behavior. -- `packages/control-plane/test/integration/service-auth.test.ts`: actor-backed service relationship - isolation. -- `packages/control-plane/test/integration/d1-session-index.test.ts`: creator projection, missing - projection, and lifecycle capability behavior. -- `packages/control-plane/test/integration/user-merge.test.ts`: relationship collision precedence. -- `public/docs/internal/2026-08-28-rbac-design.md`: stated RBAC model and observed documentation - contradictions. -- Git commit `69d32c6`: changed Member read and collaboration from own to any while retaining the - relationship projection for narrower operations. diff --git a/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md b/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md deleted file mode 100644 index 5934a94d1..000000000 --- a/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md +++ /dev/null @@ -1,193 +0,0 @@ -# Design: Workspace-Wide Session Authorization - -**Date:** 2026-08-30 - -**Status:** Accepted - -**Research:** [2026-08-30-session-access-research.md](./2026-08-30-session-access-research.md) - -## Summary - -Open-Inspect sessions are workspace-wide resources. An active user may perform an operation on every -session when their workspace role grants that operation. Session creator and participant -relationships do not grant, narrow, or revoke authorization. - -Session authorization uses unscoped operation permissions. Actor-backed bot requests intersect the -represented user's current role with the bot service's fixed capability ceiling, without applying a -session relationship check. - -Creator attribution, participant identity, sandbox capability binding, and WebSocket authorization -remain supported concerns, but none is a session access-control list. - -## Context - -Before workspace RBAC, authenticated users could operate across sessions without a creator or -participant authorization boundary. The RBAC foundation introduced `.own` and `.any` session -permission pairs and a D1 `session_access` projection. Built-in Members still received -workspace-wide read and collaboration, while lifecycle, sandbox access, deletion, participant -management, and bot requests became relationship-dependent. - -That partial relationship model does not match the product's multiplayer behavior. It also creates -two inconsistent participant stores: D1 relationships used for authorization and Session Durable -Object participants used for message identity, presence, SCM metadata, and WebSocket tokens. -Different contribution paths update those stores differently. - -## Decisions - -### Workspace-wide operations - -Session permissions are operation permissions without resource scope: - -- `sessions.read` -- `sessions.collaborate` -- `sessions.create` -- `sessions.lifecycle` -- `sessions.sandbox_access` -- `sessions.delete` - -A granted session operation applies to every session in the workspace. No route or WebSocket -authorization check consults creator or participant relationships. - -Deletion is workspace-scoped. Creator-only deletion is explicitly deferred and is not part of this -RBAC change. - -### Built-in roles - -Built-in roles distinguish which operations a user may perform, not which sessions they may target: - -| Role | Session behavior | -| ------------- | ----------------------------------------------------------------------------------- | -| Owner | Every session operation across the workspace. | -| Administrator | Every session operation across the workspace. | -| Member | Create, read, collaborate, manage lifecycle, access sandboxes, and delete sessions. | -| Viewer | Read every session; no create, collaborate, lifecycle, sandbox, or delete access. | - -Custom roles may contain any registered session operation permission. Custom roles cannot express -private, invitation-only, creator-only, or participant-only session access. - -### Actor-backed services - -A bot service acting for a human uses the intersection of two operation sets: - -```text -effective operations = actor role permissions intersect service capability ceiling -``` - -The represented actor must resolve to an active canonical workspace user. The service cannot exceed -the actor's role or its own ceiling. If both grant `sessions.collaborate`, the actor may collaborate -on any session, including a session created by another user. This preserves multiplayer Slack, -GitHub, and Linear workflows. - -Actorless service calls remain limited to narrow route-specific grants. - -### Creator attribution - -`sessions.user_id` records the canonical user responsible for creating a session. It supports -display, filtering, auditing, credential selection, automation lineage, and other attribution needs. -It is not an authorization relationship. - -The `Mine` session-list filter continues to select sessions by creator attribution. It is a user -filter, not an access boundary. - -### Participant identity - -Session Durable Object participants identify message authors and connected clients. They may retain: - -- provider identity and canonical user linkage; -- display and SCM metadata; -- message attribution; -- presence identity; -- WebSocket token ownership. - -Participant existence and the persisted `owner` or `member` value do not authorize session -operations. Joining or contributing to a session does not create a separate authorization grant. - -Participant-management APIs that exist only to maintain access-control relationships are removed. -Runtime participant creation required for attribution remains internal to contribution and -WebSocket-token flows. - -### WebSockets - -WebSocket token issuance and subscription require an active canonical user with -`sessions.collaborate`. Tokens remain bound to their session and participant identity. Subscription -authorization is rechecked through bounded leases so suspension or role changes affect live access. - -The authorization recheck evaluates active workspace membership and `sessions.collaborate`; it does -not evaluate creator or participant access records. - -### Sandbox capabilities - -Human or actor-backed requests for sandbox credentials require `sessions.sandbox_access`, which -applies workspace-wide. Sandbox-originated control-plane requests continue to authenticate with a -session-bound sandbox capability and remain restricted to that session. - -Human workspace authorization and sandbox capability binding are separate security boundaries. - -### Lifecycle and state checks - -Lifecycle routes require `sessions.lifecycle` for every session. Session state-machine checks, -queued-work checks, and sandbox runtime constraints continue to apply. - -Durable Object participant existence is not a lifecycle authorization condition. Rename, archive, -and unarchive follow the same workspace permission policy as stop, retry, and refresh. - -### Service and UI metadata - -Session lists are not filtered by authorization relationships. Query filters such as creator and -status remain supported. - -The web client derives lifecycle-control visibility from the current user's workspace -`sessions.lifecycle` permission. Session list and inbox responses contain session data, not -authorization presentation metadata; lifecycle endpoints remain authoritative. - -## Removed Model - -The RBAC foundation does not include: - -- a D1 `session_access` table; -- creator or participant authorization projections; -- `.own` and `.any` session permission pairs; -- relationship-filtered session or inbox queries; -- relationship activation during WebSocket token issuance; -- relationship-aware user merge behavior; -- creator-only deletion or participant management; -- bot-specific narrowing to sessions associated with the represented actor. - -Because this schema and permission model were introduced on the unshipped RBAC branch, they are -removed directly from the branch migration and permission registry rather than retained as a -compatibility layer. - -## Deferred Features - -Private, invitation-only, creator-restricted, or participant-restricted sessions require a separate -product design. Such a design must address visibility, invitations, removal, revocation, historical -participants, bot behavior, parent-child sessions, cross-store consistency, migration, and UI. - -No relationship schema or permission identifiers are retained speculatively for that future work. - -## Invariants - -- A workspace permission has the same meaning for browser users and represented bot actors. -- A service may narrow an actor's operations but may not expand them. -- Session creator and participant data are attribution and runtime identity, not authorization. -- Every user with `sessions.read` can read and list every session. -- Every user with `sessions.collaborate` can contribute to every session. -- Every user with `sessions.lifecycle` can invoke lifecycle operations on every session. -- Every user with `sessions.sandbox_access` can request sandbox access for every session. -- Every user with `sessions.delete` can delete every session. -- Sandbox credentials remain bound to one session regardless of human workspace permissions. -- Suspension and role changes apply to new HTTP requests and bounded-lifetime WebSocket leases. - -## Verification - -The implementation must cover: - -- a role-by-operation HTTP authorization matrix; -- cross-user browser collaboration; -- cross-user actor-backed bot listing and collaboration; -- service ceiling denial when the actor role permits an operation the service does not; -- Viewer read access and mutation denial; -- workspace-wide lifecycle, sandbox, and deletion behavior for permitted roles; -- WebSocket subscription reauthorization after role or suspension changes; -- session-bound sandbox authentication; -- lifecycle consistency across rename, archive, unarchive, stop, retry, and refresh.