diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9f75ac73..e62952766 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 @@ -77,6 +79,12 @@ jobs: - name: Test complexity reporter run: npm run test:lint-complexity + - 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-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 aef1af3ae..5e6c2bbee 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,15 @@ "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: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", "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": { @@ -39,7 +42,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/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..bab14ebae --- /dev/null +++ b/packages/control-plane/src/authorization/service.ts @@ -0,0 +1,169 @@ +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 === "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 new file mode 100644 index 000000000..8830ccf91 --- /dev/null +++ b/packages/control-plane/src/db/authorization-store.test.ts @@ -0,0 +1,89 @@ +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", + "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, + }); + }); + + 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..1bd249121 --- /dev/null +++ b/packages/control-plane/src/db/authorization-store.ts @@ -0,0 +1,493 @@ +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"; +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: RoleReference | null; +} + +/** Persistence view of a role and the number of users currently assigned to it. */ +export type AuthorizationRoleRecord = RoleReference & { + 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: "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 + ? toRoleReference(row.role_id, row.role_key, row.role_name) + : null, + }; +} + +function toRoleRecord(row: RoleRow): AuthorizationRoleRecord { + return { + ...toRoleReference(row.id, row.key, 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: toRoleReference(row.role_id, row.role_key, 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], + }, + 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([ + 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], + }, + 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[] = [ + 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?: Array<{ status: NotFoundStatus; condition: 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(); + 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' + ${notFoundCases} + WHEN NOT (${resourceCondition.sql}) THEN 'conflict' + ELSE 'applied' + END AS status` + ) + .bind( + ...actor.values, + ...(options?.notFound?.flatMap(({ condition }) => condition.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 !== "role_not_found" && + status !== "member_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..da626a47d 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 @@ -20,14 +20,12 @@ import type { SqlDatabase, 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. - * - Browser sessions (`auth_sessions`) are re-pointed, not deleted — the - * merged person stays signed in as the survivor. + * - 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. * - 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 +55,199 @@ 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", + "providerAccountsCreatedRepointed", + "providerAccountsUpdatedRepointed", + "providerAccountDefaultsCreatedRepointed", + "providerAccountDefaultsUpdatedRepointed", + "skillsCreatedRepointed", + "skillsUpdatedRepointed", + "skillRevisionsCreatedRepointed", + "skillAssignmentsCreatedRepointed", + "skillCatalogGenerationsAdvanced", + "keyboardShortcutPreferencesDeduped", + "keyboardShortcutPreferencesRepointed", + "auditEventsCreated", + "canonicalEmailBackfilled", + "usersDeleted", +] as const; + +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; + 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 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", + 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_CATALOG_GENERATION_OPERATION, + ...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 +255,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,21 +267,64 @@ 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`); } // 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 }>(); + .first<{ + id: string; + email: string | null; + email_verified: number; + suspended_at: number | null; + }>(); + 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 ((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) { + 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,80 +340,88 @@ 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). + 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( - "identitiesDeduped", + "auditEventsCreated", 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 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 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', + 'workspace.user_merged', 'user', ?, ?, 'operator_merge' + )` ) - .bind(loserId, survivorId) + .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( - "identitiesRepointed", - db.prepare(`UPDATE user_identities SET user_id = ? WHERE user_id = ?`).bind(survivorId, loserId) + SKILL_CATALOG_GENERATION_OPERATION.key, + SKILL_CATALOG_GENERATION_OPERATION.execute(db, survivorId, loserId) ); + + // Merge items before deleting colliding skill profiles. add( - "readStatesDeduped", + "skillProfileItemsMerged", 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 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) - ); - 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) ); + addOperations(SKILL_PROFILE_OPERATIONS); + + // Preserve the survivor's RBAC assignment before deleting the loser. add( - "scmTokensRepointed", - db.prepare(`UPDATE user_scm_tokens 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); add("usersDeleted", db.prepare(`DELETE FROM users WHERE id = ?`).bind(loserId)); if (backfillEmail) { @@ -213,11 +444,11 @@ 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][]) { - counts[key] = results[index]?.meta.changes ?? 0; + for (const [key, index] of Object.entries(track) as [UserMergeCountKey, number][]) { + 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; @@ -228,19 +459,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 +468,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 +516,12 @@ 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), + // 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/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..a73bdb9ba --- /dev/null +++ b/packages/control-plane/test/integration/rbac-foundation.test.ts @@ -0,0 +1,143 @@ +import { env } from "cloudflare:test"; +import { + BUILT_IN_ROLE_REGISTRY, + PERMISSION_IDS, + permissionsForBuiltInRole, +} from "@open-inspect/shared/rbac"; +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 () => { + 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); + }); + + 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/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..1821b115f 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, @@ -61,6 +62,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 +89,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 +111,12 @@ describe("mergeUsers", () => { expect(result.counts).toMatchObject({ identitiesRepointed: 1, sessionsRepointed: 1, - authSessionsRepointed: 1, + authSessionsDeleted: 1, automationsOwnedRepointed: 1, automationsCreatedRepointed: 1, scmTokensRepointed: 1, + skillProfilesRepointed: 1, + skillCatalogGenerationsAdvanced: 1, readStatesDeduped: 1, readStatesRepointed: 1, usersDeleted: 1, @@ -119,12 +132,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 +146,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 +160,24 @@ 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, + 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 +235,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 +253,181 @@ 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("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 }); + 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 }); @@ -235,6 +444,68 @@ 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("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 }); + 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/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..352098523 --- /dev/null +++ b/packages/shared/src/rbac.test.ts @@ -0,0 +1,153 @@ +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, + roleReferenceSchema, +} 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("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); + 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..caad28048 --- /dev/null +++ b/packages/shared/src/rbac.ts @@ -0,0 +1,254 @@ +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[]; +/** 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 = [ + "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"; +} + +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(roleReferenceShape) + .strict() + .superRefine(validateRoleIdentity); + +/** Validates an administrative role view with effective grants and assignment count. */ +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 + .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; +/** 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. */ +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..5f9f9eeb3 --- /dev/null +++ b/scripts/bootstrap-workspace-owner.test.ts @@ -0,0 +1,376 @@ +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, run } 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'/); + }); +}); + +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 new file mode 100644 index 000000000..50976d0a6 --- /dev/null +++ b/scripts/bootstrap-workspace-owner.ts @@ -0,0 +1,304 @@ +/** + * 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; +} + +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); + 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, runner: WranglerRunner): string { + const sql = buildBootstrapSql({ userId, execute: false, auditId: "unused", now: 0 }); + 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, + 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, runner); + 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"); + 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, + now, + }), + { encoding: "utf8", mode: 0o600 } + ); + executionRows = reportRows(runner(options.database, ["--file", sqlPath])); + } finally { + await rm(directory, { recursive: true, force: true }); + } + + 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." + ); +} + +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/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 fe1fcee8c..63306815f 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 result-bearing D1 batch. * * Usage: * node --experimental-transform-types scripts/merge-split-users.ts \ @@ -26,6 +25,8 @@ */ import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import type { SqlDatabase, SqlResult, @@ -43,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") { @@ -74,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 { @@ -109,17 +124,18 @@ 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. + // 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[]> { const rendered = statements.map((entry) => (entry as { render(): string }).render()); - return this.execute(rendered).map((result) => toSqlResult(result)); + 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[] { @@ -127,6 +143,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,10 +154,9 @@ 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 }); + const child = this.runner(args); if (child.status !== 0) { throw new Error(`wrangler d1 execute failed:\n${child.stderr || child.stdout}`); } @@ -211,25 +230,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, @@ -258,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; + }); +} diff --git a/terraform/d1/migrations/0071_rbac_foundation.sql b/terraform/d1/migrations/0071_rbac_foundation.sql new file mode 100644 index 000000000..5c093b8dc --- /dev/null +++ b/terraform/d1/migrations/0071_rbac_foundation.sql @@ -0,0 +1,81 @@ +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 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' + )) + ) +); + +-- 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"