diff --git a/packages/control-plane/src/routes/rbac.ts b/packages/control-plane/src/routes/rbac.ts index 33a2fcb3e..78ccb528b 100644 --- a/packages/control-plane/src/routes/rbac.ts +++ b/packages/control-plane/src/routes/rbac.ts @@ -1,4 +1,14 @@ -import { AuthorizationError, AuthorizationService } from "../authorization/service"; +import { isCanonicalUserId } from "@open-inspect/shared/user-id"; +import { + replaceMemberRoleInputSchema, + replaceMemberStatusInputSchema, +} from "@open-inspect/shared/rbac"; +import { ZodError } from "zod"; +import { + AuthorizationError, + AuthorizationService, + RbacConflictError, +} from "../authorization/service"; import type { Env } from "../types"; import type { Route } from "./shared"; import { @@ -7,6 +17,7 @@ import { defineRoutes, error, json, + parseJsonBody, requirePermission, type UserRouteContext, } from "./shared"; @@ -22,9 +33,21 @@ function rbacErrorResponse(cause: unknown): Response { cause.status ); } + if (cause instanceof RbacConflictError) { + return json({ error: cause.message, code: "rbac_conflict" }, 409); + } + if (cause instanceof ZodError) return error("Invalid request body", 400); return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503); } +function decodePathSegment(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + async function handleGetCurrentAuthorization( _request: Request, _env: Env, @@ -61,10 +84,11 @@ async function handleGetRole( ): Promise { const service = new AuthorizationService(ctx.db); try { - const role = await service.getRole(decodeURIComponent(match.groups!.id)); + const roleId = decodePathSegment(match.groups!.id); + if (roleId === null) return error("Invalid role ID", 400); + const role = await service.getRole(roleId); return role ? json(role) : error("Role not found", 404); } catch (cause) { - if (cause instanceof URIError) return error("Invalid role ID", 400); return rbacErrorResponse(cause); } } @@ -83,6 +107,60 @@ async function handleListMembers( } } +async function handleReplaceMemberRole( + request: Request, + _env: Env, + match: RegExpMatchArray, + ctx: UserRouteContext +): Promise { + const targetUserId = decodePathSegment(match.groups!.id); + if (targetUserId === null || !isCanonicalUserId(targetUserId)) { + return error("Invalid user ID", 400); + } + const body = await parseJsonBody(request); + if (body instanceof Response) return body; + const service = new AuthorizationService(ctx.db); + try { + const parsed = replaceMemberRoleInputSchema.parse(body); + await service.replaceMemberRole({ + targetUserId, + roleId: parsed.roleId, + actorUserId: ctx.principal.userId, + requestId: ctx.request_id, + }); + return new Response(null, { status: 204 }); + } catch (cause) { + return rbacErrorResponse(cause); + } +} + +async function handleReplaceMemberStatus( + request: Request, + _env: Env, + match: RegExpMatchArray, + ctx: UserRouteContext +): Promise { + const targetUserId = decodePathSegment(match.groups!.id); + if (targetUserId === null || !isCanonicalUserId(targetUserId)) { + return error("Invalid user ID", 400); + } + const body = await parseJsonBody(request); + if (body instanceof Response) return body; + const service = new AuthorizationService(ctx.db); + try { + const parsed = replaceMemberStatusInputSchema.parse(body); + await service.replaceMemberStatus({ + targetUserId, + suspended: parsed.suspended, + actorUserId: ctx.principal.userId, + requestId: ctx.request_id, + }); + return new Response(null, { status: 204 }); + } catch (cause) { + return rbacErrorResponse(cause); + } +} + export const rbacRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [ { method: "GET", @@ -112,4 +190,18 @@ export const rbacRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [ cacheControl: "private, no-store", handler: handleListMembers, }, + { + method: "PUT", + pattern: /^\/members\/(?[^/]+)\/role$/, + authorization: requirePermission("workspace.members.manage"), + cacheControl: "private, no-store", + handler: handleReplaceMemberRole, + }, + { + method: "PUT", + pattern: /^\/members\/(?[^/]+)\/status$/, + authorization: requirePermission("workspace.members.manage"), + cacheControl: "private, no-store", + handler: handleReplaceMemberStatus, + }, ]); diff --git a/packages/control-plane/test/integration/rbac-routes.test.ts b/packages/control-plane/test/integration/rbac-routes.test.ts new file mode 100644 index 000000000..2130689b7 --- /dev/null +++ b/packages/control-plane/test/integration/rbac-routes.test.ts @@ -0,0 +1,558 @@ +import { env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AuthorizationService } from "../../src/authorization/service"; +import { UserStore } from "../../src/db/user-store"; +import { mergeUsers } from "../../src/db/user-merge"; +import { cleanD1Tables } from "./cleanup"; +import { serviceFetch, sqlDatabase } from "./helpers"; + +describe("RBAC routes", () => { + beforeEach(cleanD1Tables); + afterEach(cleanD1Tables); + + async function seedOwner(): Promise { + expect((await serviceFetch("https://cp.test/me/authorization")).status).toBe(200); + const user = await env.DB.prepare( + "SELECT id FROM users WHERE email = 'browser@test.local'" + ).first<{ + id: string; + }>(); + if (!user) throw new Error("Browser user was not seeded"); + await env.DB.prepare( + "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = ?" + ) + .bind(user.id) + .run(); + return user.id; + } + + it("keeps ordinary browser users as Member without an Owner assignment", async () => { + const first = await serviceFetch("https://cp.test/me/authorization", { + initialUserRole: "member", + }); + expect(first.status).toBe(200); + await expect(first.json()).resolves.toMatchObject({ + suspendedAt: null, + role: { key: "member" }, + }); + }); + + it("assigns Member to identities created after the migration boundary", async () => { + const user = await new UserStore(sqlDatabase(env.DB)).createUser({ + displayName: "New Member", + email: "member@example.com", + emailVerified: true, + }); + + const assignment = await env.DB.prepare( + `SELECT r.key FROM user_role_assignments ura + JOIN roles r ON r.id = ura.role_id WHERE ura.user_id = ?` + ) + .bind(user.id) + .first(); + expect(assignment).toEqual({ key: "member" }); + }); + + it("assigns Member at the database boundary for Better Auth and old-worker inserts", async () => { + const userId = "22222222222222222222222222222222"; + await env.DB.prepare( + `INSERT INTO users + (id, display_name, email, email_verified, avatar_url, created_at, updated_at) + VALUES (?, 'Direct User', 'direct@example.com', 1, NULL, 1, 1)` + ) + .bind(userId) + .run(); + + expect( + await env.DB.prepare( + `SELECT r.key FROM user_role_assignments ura + JOIN roles r ON r.id = ura.role_id WHERE ura.user_id = ?` + ) + .bind(userId) + .first() + ).toEqual({ key: "member" }); + }); + + it("suspends an emailed member without an Owner assignment", async () => { + await serviceFetch("https://cp.test/me/authorization"); + const actor = await env.DB.prepare( + "SELECT id FROM users WHERE email = 'browser@test.local'" + ).first<{ id: string }>(); + const member = await new UserStore(sqlDatabase(env.DB)).createUser({ + displayName: "Suspendable Member", + email: "member@example.com", + emailVerified: true, + }); + const service = new AuthorizationService(sqlDatabase(env.DB)); + + await service.replaceMemberStatus({ + targetUserId: member.id, + suspended: true, + actorUserId: actor!.id, + requestId: "suspend-without-bootstrap", + }); + + await expect(service.getEffectiveAuthorization(member.id)).resolves.toMatchObject({ + suspendedAt: expect.any(Number), + }); + }); + + it("fails closed when an existing user has no role assignment", async () => { + await serviceFetch("https://cp.test/me/authorization"); + const user = await env.DB.prepare( + "SELECT id FROM users WHERE email = 'browser@test.local'" + ).first<{ id: string }>(); + await env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?") + .bind(user!.id) + .run(); + + const response = await serviceFetch("https://cp.test/me/authorization"); + const personalRoute = await serviceFetch("https://cp.test/keyboard-shortcuts"); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code: "assignment_required" }); + expect(personalRoute.status).toBe(403); + await expect(personalRoute.json()).resolves.toMatchObject({ code: "assignment_required" }); + expect( + await env.DB.prepare("SELECT * FROM user_role_assignments WHERE user_id = ?") + .bind(user!.id) + .first() + ).toBeNull(); + }); + + it("uses code-owned permissions for built-in role authorization", async () => { + await serviceFetch("https://cp.test/me/authorization", { initialUserRole: "member" }); + const member = await env.DB.prepare( + "SELECT id FROM users WHERE email = 'browser@test.local'" + ).first<{ id: string }>(); + const permission = "workspace.roles.read"; + await env.DB.prepare( + "INSERT INTO role_permissions (role_id, permission_id) VALUES ('role_builtin_member', ?)" + ) + .bind(permission) + .run(); + + try { + const authorization = await new AuthorizationService( + sqlDatabase(env.DB) + ).getEffectiveAuthorization(member!.id); + expect(authorization.permissions).not.toContain(permission); + expect((await serviceFetch("https://cp.test/roles")).status).toBe(403); + } finally { + await env.DB.prepare( + "DELETE FROM role_permissions WHERE role_id = 'role_builtin_member' AND permission_id = ?" + ) + .bind(permission) + .run(); + } + }); + + it("never resolves ownership transfer from a custom role", async () => { + await serviceFetch("https://cp.test/me/authorization"); + const user = await env.DB.prepare( + "SELECT id FROM users WHERE email = 'browser@test.local'" + ).first<{ id: string }>(); + const roleId = "role_custom_owner"; + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO roles + (id, key, name, normalized_name, description, is_system) + VALUES (?, NULL, 'Custom Owner', 'custom owner', NULL, 0)` + ).bind(roleId), + env.DB.prepare( + `INSERT INTO role_permissions (role_id, permission_id) + VALUES (?, 'workspace.roles.read'), (?, 'workspace.transfer_ownership')` + ).bind(roleId, roleId), + env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?").bind( + roleId, + user!.id + ), + ]); + + const authorization = await new AuthorizationService( + sqlDatabase(env.DB) + ).getEffectiveAuthorization(user!.id); + expect(authorization.permissions).toContain("workspace.roles.read"); + expect(authorization.permissions).not.toContain("workspace.transfer_ownership"); + }); + + it("requires sessions.create in addition to parent collaboration when spawning a child", async () => { + await serviceFetch("https://cp.test/me/authorization"); + const user = await env.DB.prepare( + "SELECT id FROM users WHERE email = 'browser@test.local'" + ).first<{ id: string }>(); + const roleId = "role_child_collaborator"; + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO roles + (id, key, name, normalized_name, description, is_system) + VALUES (?, NULL, 'Child Collaborator', 'child collaborator', NULL, 0)` + ).bind(roleId), + env.DB.prepare( + "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, 'sessions.collaborate')" + ).bind(roleId), + env.DB.prepare(`UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?`).bind( + roleId, + user!.id + ), + ]); + + const response = await serviceFetch("https://cp.test/sessions/parent/children", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: "Investigate" }), + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + code: "permission_required", + permission: "sessions.create", + }); + }); + + it("denies sensitive business mutations to Viewer", async () => { + await serviceFetch("https://cp.test/me/authorization"); + const user = await env.DB.prepare( + "SELECT id FROM users WHERE email = 'browser@test.local'" + ).first<{ id: string }>(); + await env.DB.prepare( + "UPDATE user_role_assignments SET role_id = 'role_builtin_viewer' WHERE user_id = ?" + ) + .bind(user!.id) + .run(); + + const response = await serviceFetch("https://cp.test/secrets", { + method: "PUT", + body: JSON.stringify({ secrets: { SHOULD_NOT_WRITE: "secret" } }), + headers: { "Content-Type": "application/json" }, + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + code: "permission_required", + permission: "global_secrets.manage", + }); + + await env.DB.prepare( + `INSERT INTO sessions (id, repo_owner, repo_name, status, created_at, updated_at) + VALUES ('viewer-session', 'acme', 'app', 'completed', 1, 1)` + ).run(); + const sessionDelete = await serviceFetch("https://cp.test/sessions/viewer-session", { + method: "DELETE", + }); + expect(sessionDelete.status).toBe(403); + await expect(sessionDelete.json()).resolves.toMatchObject({ + code: "permission_required", + permission: "sessions.delete", + }); + + const read = await serviceFetch("https://cp.test/sessions/viewer-session"); + expect(read.status).not.toBe(403); + + for (const [path, method, permission, body] of [ + ["/sessions", "POST", "sessions.create", { title: "Denied", model: "test/model" }], + ["/sessions/viewer-session/prompt", "POST", "sessions.collaborate", { content: "Denied" }], + ["/sessions/viewer-session/stop", "POST", "sessions.lifecycle", undefined], + ["/sessions/viewer-session/sandbox-access", "GET", "sessions.sandbox_access", undefined], + ["/skill-profiles", "GET", "skill_profiles.manage_own", undefined], + ["/skill-profiles", "POST", "skill_profiles.manage_own", { name: "Denied", skillIds: [] }], + ["/skill-profiles/profile-1", "PATCH", "skill_profiles.manage_own", { name: "Denied" }], + ["/skill-profiles/profile-1", "DELETE", "skill_profiles.manage_own", undefined], + ["/model-provider-accounts", "GET", "provider_accounts.read", undefined], + ["/model-provider-account-defaults", "GET", "provider_accounts.read", undefined], + ["/model-provider-accounts/legacy-credentials", "GET", "provider_accounts.read", undefined], + ] as const) { + const denied = await serviceFetch(`https://cp.test${path}`, { + method, + ...(body + ? { body: JSON.stringify(body), headers: { "Content-Type": "application/json" } } + : {}), + }); + expect(denied.status, path).toBe(403); + await expect(denied.json()).resolves.toMatchObject({ + code: "permission_required", + permission, + }); + } + }); + + it("allows Members to discover and delete sessions workspace-wide", async () => { + await serviceFetch("https://cp.test/me/authorization", { initialUserRole: "member" }); + const member = await env.DB.prepare( + "SELECT id FROM users WHERE email = 'browser@test.local'" + ).first<{ id: string }>(); + const other = await new UserStore(sqlDatabase(env.DB)).createUser({ displayName: "Other" }); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO sessions (id, repo_owner, repo_name, status, created_at, updated_at, user_id) + VALUES ('member-session', 'acme', 'app', 'completed', 1, 1, ?)` + ).bind(member!.id), + env.DB.prepare( + `INSERT INTO sessions (id, repo_owner, repo_name, status, created_at, updated_at, user_id) + VALUES ('other-session', 'acme', 'app', 'completed', 2, 2, ?)` + ).bind(other.id), + env.DB.prepare( + `INSERT INTO sessions (id, repo_owner, repo_name, status, created_at, updated_at, user_id) + VALUES ('unjoined-session', 'acme', 'app', 'completed', 3, 3, ?)` + ).bind(other.id), + ]); + + const listed = await serviceFetch("https://cp.test/sessions"); + const lifecycle = await serviceFetch("https://cp.test/sessions/other-session/stop", { + method: "POST", + }); + const sandboxAccess = await serviceFetch( + "https://cp.test/sessions/other-session/sandbox-access" + ); + const otherDelete = await serviceFetch("https://cp.test/sessions/other-session", { + method: "DELETE", + }); + const ownDelete = await serviceFetch("https://cp.test/sessions/member-session", { + method: "DELETE", + }); + + expect(listed.status).toBe(200); + expect(lifecycle.status).not.toBe(403); + expect(sandboxAccess.status).not.toBe(403); + await expect(listed.json()).resolves.toMatchObject({ + sessions: [{ id: "unjoined-session" }, { id: "other-session" }, { id: "member-session" }], + }); + expect(otherDelete.status).toBe(200); + expect(ownDelete.status).toBe(200); + expect( + await env.DB.prepare("SELECT id FROM sessions WHERE id = 'other-session'").first() + ).toBeNull(); + }); + + it("does not let the last unsuspended Owner be suspended", async () => { + await seedOwner(); + const owner = await env.DB.prepare( + `SELECT u.id + FROM users u + JOIN user_role_assignments ura ON ura.user_id = u.id + JOIN roles r ON r.id = ura.role_id + WHERE r.key = 'owner'` + ).first<{ id: string }>(); + expect(owner).not.toBeNull(); + + const response = await serviceFetch(`https://cp.test/members/${owner!.id}/status`, { + method: "PUT", + body: JSON.stringify({ suspended: true }), + headers: { "Content-Type": "application/json" }, + }); + expect(response.status).toBe(409); + expect( + await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(owner!.id).first() + ).toEqual({ suspended_at: null }); + expect( + await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.member_status_updated'" + ).first() + ).toEqual({ count: 0 }); + }); + + it("lets an Owner suspend themselves when another unsuspended Owner exists", async () => { + const ownerId = await seedOwner(); + const otherOwner = await new UserStore(sqlDatabase(env.DB)).createUser({ + displayName: "Other Owner", + }); + await env.DB.prepare( + "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = ?" + ) + .bind(otherOwner.id) + .run(); + + const response = await serviceFetch(`https://cp.test/members/${ownerId}/status`, { + method: "PUT", + body: JSON.stringify({ suspended: true }), + headers: { "Content-Type": "application/json" }, + }); + + expect(response.status).toBe(204); + expect( + await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(ownerId).first() + ).toEqual({ suspended_at: expect.any(Number) }); + expect( + await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.member_status_updated'" + ).first() + ).toEqual({ count: 1 }); + }); + + it("lets an Owner demote themselves when another unsuspended Owner exists", async () => { + const ownerId = await seedOwner(); + const otherOwner = await new UserStore(sqlDatabase(env.DB)).createUser({ + displayName: "Other Owner", + }); + await env.DB.prepare( + "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = ?" + ) + .bind(otherOwner.id) + .run(); + + const response = await serviceFetch(`https://cp.test/members/${ownerId}/role`, { + method: "PUT", + body: JSON.stringify({ roleId: "role_builtin_administrator" }), + headers: { "Content-Type": "application/json" }, + }); + + expect(response.status).toBe(204); + expect( + await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.member_role_updated'" + ).first() + ).toEqual({ count: 1 }); + }); + + it("does not let the last unsuspended Owner demote themselves", async () => { + const ownerId = await seedOwner(); + + const response = await serviceFetch(`https://cp.test/members/${ownerId}/role`, { + method: "PUT", + body: JSON.stringify({ roleId: "role_builtin_administrator" }), + headers: { "Content-Type": "application/json" }, + }); + + expect(response.status).toBe(409); + expect( + await env.DB.prepare( + `SELECT r.key FROM user_role_assignments ura + JOIN roles r ON r.id = ura.role_id WHERE ura.user_id = ?` + ) + .bind(ownerId) + .first() + ).toEqual({ key: "owner" }); + }); + + it.each(["role", "status"] as const)( + "rejects malformed percent encoding in member %s routes", + async (operation) => { + await seedOwner(); + + const response = await serviceFetch(`https://cp.test/members/%/${operation}`, { + method: "PUT", + body: JSON.stringify( + operation === "role" ? { roleId: "role_builtin_member" } : { suspended: true } + ), + headers: { "Content-Type": "application/json" }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "Invalid user ID" }); + } + ); + + it("requires an explicit unsuspended Owner assignment before merging an Owner", async () => { + const store = new UserStore(sqlDatabase(env.DB)); + const survivor = await store.createUser({ displayName: "Survivor" }); + const loser = await store.createUser({ displayName: "Owner" }); + await env.DB.batch([ + env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?").bind(survivor.id), + env.DB.prepare( + "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = ?" + ).bind(loser.id), + ]); + + await expect( + mergeUsers(sqlDatabase(env.DB), { + survivorId: survivor.id, + loserId: loser.id, + dryRun: false, + }) + ).rejects.toThrow("Resolve conflicting user roles before merging"); + }); + + it("rejects privileged mutations when the actor authorization changes", async () => { + const ownerId = await seedOwner(); + const member = await new UserStore(sqlDatabase(env.DB)).createUser({ + displayName: "Target Member", + }); + const service = new AuthorizationService(sqlDatabase(env.DB)); + await service.requirePermission(ownerId, "workspace.members.manage"); + await env.DB.prepare( + "UPDATE user_role_assignments SET role_id = 'role_builtin_member' WHERE user_id = ?" + ) + .bind(ownerId) + .run(); + await expect( + service.replaceMemberRole({ + targetUserId: member.id, + roleId: "role_builtin_administrator", + actorUserId: ownerId, + requestId: "stale-member-role-request", + }) + ).rejects.toThrow("Actor authorization changed"); + await expect( + service.replaceMemberStatus({ + targetUserId: member.id, + suspended: true, + actorUserId: ownerId, + requestId: "stale-member-request", + }) + ).rejects.toThrow("Actor authorization changed"); + + expect(await service.getEffectiveAuthorization(member.id)).toMatchObject({ + role: { key: "member" }, + }); + expect( + await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(member.id).first() + ).toEqual({ suspended_at: null }); + expect( + await env.DB.prepare( + `SELECT COUNT(*) AS count FROM authorization_audit_events + WHERE request_id IN ( + 'stale-member-role-request', 'stale-member-request' + )` + ).first() + ).toEqual({ count: 0 }); + }); + + it("returns authorization unavailable for an unexpected mutation database failure", async () => { + await seedOwner(); + await env.DB.prepare( + `CREATE TRIGGER fail_member_audit + BEFORE INSERT ON authorization_audit_events + WHEN NEW.action = 'workspace.member_status_updated' + BEGIN + SELECT RAISE(ABORT, 'forced database failure'); + END` + ).run(); + + try { + const member = await new UserStore(sqlDatabase(env.DB)).createUser({ displayName: "Member" }); + const response = await serviceFetch(`https://cp.test/members/${member.id}/status`, { + method: "PUT", + body: JSON.stringify({ suspended: true }), + headers: { "Content-Type": "application/json" }, + }); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: "Authorization unavailable", + code: "authorization_unavailable", + }); + expect( + await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(member.id).first() + ).toEqual({ suspended_at: null }); + expect( + await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.member_status_updated'" + ).first() + ).toEqual({ count: 0 }); + } finally { + await env.DB.prepare("DROP TRIGGER fail_member_audit").run(); + } + }); + + it("rejects suspended users at the backend after reauthentication", async () => { + await seedOwner(); + await env.DB.prepare("UPDATE users SET suspended_at = 1").run(); + + const response = await serviceFetch("https://cp.test/repos"); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Forbidden", + code: "active_user_required", + }); + }); +}); diff --git a/packages/web/src/app/(app)/settings/page.test.tsx b/packages/web/src/app/(app)/settings/page.test.tsx index 131d37a54..cc618d8bc 100644 --- a/packages/web/src/app/(app)/settings/page.test.tsx +++ b/packages/web/src/app/(app)/settings/page.test.tsx @@ -7,12 +7,16 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import SettingsPage from "./page"; import { SettingsViewportProvider } from "@/components/settings/settings-viewport-context"; +import { PERMISSION_IDS } from "@open-inspect/shared/rbac"; expect.extend(matchers); const mocks = vi.hoisted(() => ({ tab: null as string | null, repoImagesEnabled: true, + allowedPermissions: new Set(), + authorization: { permissions: [] as string[] }, + hasPermission: (permission: string) => mocks.allowedPermissions.has(permission), })); vi.mock("next/navigation", () => ({ @@ -23,6 +27,14 @@ vi.mock("@/lib/sandbox-provider", () => ({ supportsRepoImages: () => mocks.repoImagesEnabled, })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + authorization: mocks.authorization, + loading: false, + hasPermission: mocks.hasPermission, + }), +})); + vi.mock("@/components/settings/secrets-settings", () => ({ SecretsSettings: () =>
Secrets panel
, })); @@ -66,6 +78,8 @@ vi.mock("@/components/settings/mcp-servers-settings", () => ({ beforeEach(() => { mocks.tab = null; mocks.repoImagesEnabled = true; + mocks.allowedPermissions = new Set(PERMISSION_IDS); + mocks.authorization.permissions = [...PERMISSION_IDS]; window.history.replaceState(null, "", "/settings"); vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { callback(0); @@ -94,8 +108,8 @@ describe("SettingsPage mobile navigation", () => { await user.click(screen.getByRole("button", { name: /Appearance/ })); - expect(screen.getByRole("heading", { name: "Appearance" })).toHaveFocus(); - expect(screen.getByText("Appearance panel")).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Appearance" })).toHaveFocus(); + expect(await screen.findByText("Appearance panel")).toBeInTheDocument(); expect(window.location.href).toContain("/settings?tab=appearance"); expect(window.history.state).toMatchObject({ openInspectSettingsDetail: true }); @@ -139,6 +153,42 @@ describe("SettingsPage mobile navigation", () => { expect(window.location.search).toBe(""); }); + it("redirects an unauthorized deep link to the first available panel", () => { + mocks.tab = "secrets"; + mocks.allowedPermissions = new Set(); + mocks.authorization.permissions = []; + window.history.replaceState(null, "", "/settings?tab=secrets"); + + renderSettingsPage(); + + expect(screen.getByRole("heading", { name: "Appearance" })).toBeInTheDocument(); + expect(screen.getByText("Appearance panel")).toBeInTheDocument(); + expect(screen.queryByText("Secrets panel")).not.toBeInTheDocument(); + }); + + it("allows repository secret managers to open secrets", async () => { + mocks.tab = "secrets"; + mocks.allowedPermissions = new Set(["repositories.secrets.manage", "repositories.read"]); + mocks.authorization.permissions = ["repositories.secrets.manage", "repositories.read"]; + window.history.replaceState(null, "", "/settings?tab=secrets"); + + renderSettingsPage(); + + expect(await screen.findByText("Secrets panel")).toBeInTheDocument(); + }); + + it("rejects repository secret managers without repository read access", async () => { + mocks.tab = "secrets"; + mocks.allowedPermissions = new Set(["repositories.secrets.manage"]); + mocks.authorization.permissions = ["repositories.secrets.manage"]; + window.history.replaceState(null, "", "/settings?tab=secrets"); + + renderSettingsPage(); + + expect(await screen.findByText("Appearance panel")).toBeInTheDocument(); + expect(screen.queryByText("Secrets panel")).not.toBeInTheDocument(); + }); + it("uses browser history for the in-app back action", async () => { const back = vi.spyOn(window.history, "back").mockImplementation(() => undefined); const user = userEvent.setup(); diff --git a/packages/web/src/app/(app)/settings/page.tsx b/packages/web/src/app/(app)/settings/page.tsx index ee1f3d8f8..c42f5c9f2 100644 --- a/packages/web/src/app/(app)/settings/page.tsx +++ b/packages/web/src/app/(app)/settings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, useEffect, useRef, useState, type ComponentType } from "react"; +import { Suspense, useEffect, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { DEFAULT_SETTINGS_CATEGORY, @@ -11,45 +11,17 @@ import { } from "@/components/settings/settings-nav"; import { SettingsMobileHeader } from "@/components/settings/settings-mobile-header"; import { useSettingsIsMobile } from "@/components/settings/settings-viewport-context"; -import { SecretsSettings } from "@/components/settings/secrets-settings"; -import { EnvironmentsSettings } from "@/components/settings/environments-settings"; -import { ModelsSettings } from "@/components/settings/models-settings"; -import { DataControlsSettings } from "@/components/settings/data-controls-settings"; -import { KeyboardShortcutsSettings } from "@/components/settings/keyboard-shortcuts-settings"; -import { IntegrationsSettings } from "@/components/settings/integrations-settings"; -import { SandboxSettingsPage } from "@/components/settings/sandbox-settings"; -import { ScmSettingsPage } from "@/components/settings/scm-settings"; -import { ImagesSettings } from "@/components/settings/images-settings"; -import { McpServersSettings } from "@/components/settings/mcp-servers-settings"; -import { AppearanceSettings } from "@/components/settings/appearance-settings"; -import { ProviderAccountsSettings } from "@/components/settings/provider-accounts-settings"; -import { SkillsSettings } from "@/components/settings/skills-settings"; import { supportsRepoImages } from "@/lib/sandbox-provider"; - -const SETTINGS_PANELS: Record = { - appearance: AppearanceSettings, - "keyboard-shortcuts": KeyboardShortcutsSettings, - models: ModelsSettings, - "provider-accounts": ProviderAccountsSettings, - skills: SkillsSettings, - environments: EnvironmentsSettings, - secrets: SecretsSettings, - scm: ScmSettingsPage, - sandbox: SandboxSettingsPage, - images: ImagesSettings, - integrations: IntegrationsSettings, - "mcp-servers": McpServersSettings, - "data-controls": DataControlsSettings, -}; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { getSettingsPanel, resolveSettingsCategory } from "@/components/settings/settings-registry"; function SettingsPageContent() { const searchParams = useSearchParams(); const tabParam = searchParams.get("tab"); const repoImagesEnabled = supportsRepoImages(); const isMobile = useSettingsIsMobile(); - const initialCategory = isSettingsCategory(tabParam, repoImagesEnabled) - ? tabParam - : DEFAULT_SETTINGS_CATEGORY; + const { hasPermission, loading } = useCurrentUserAuthorization(); + const initialCategory = resolveSettingsCategory(tabParam, repoImagesEnabled, hasPermission); const [activeCategory, setActiveCategoryRaw] = useState(initialCategory); function selectCategory(category: SettingsCategory, trigger: HTMLButtonElement) { @@ -101,7 +73,7 @@ function SettingsPageContent() { const syncFromHistory = () => { const requestedCategory = new URLSearchParams(window.location.search).get("tab"); const nextCategory = isSettingsCategory(requestedCategory, repoImagesEnabled) - ? requestedCategory + ? resolveSettingsCategory(requestedCategory, repoImagesEnabled, hasPermission) : null; if (nextCategory) { setActiveCategoryRaw(nextCategory); @@ -121,27 +93,34 @@ function SettingsPageContent() { window.addEventListener("popstate", syncFromHistory); return () => window.removeEventListener("popstate", syncFromHistory); - }, [isMobile, repoImagesEnabled]); + }, [hasPermission, isMobile, repoImagesEnabled]); // Sync state when searchParams change via client-side navigation useEffect(() => { if (isSettingsCategory(tabParam, repoImagesEnabled)) { - setActiveCategoryRaw(tabParam); + setActiveCategoryRaw(resolveSettingsCategory(tabParam, repoImagesEnabled, hasPermission)); setMobileView("detail"); return; } if (!isMobile || !mobileTriggerRef.current) { - setActiveCategoryRaw(DEFAULT_SETTINGS_CATEGORY); + setActiveCategoryRaw(resolveSettingsCategory(null, repoImagesEnabled, hasPermission)); } setMobileView("list"); - }, [isMobile, repoImagesEnabled, tabParam]); + }, [hasPermission, isMobile, repoImagesEnabled, tabParam]); - const renderedCategory = isSettingsCategory(activeCategory, repoImagesEnabled) - ? activeCategory - : DEFAULT_SETTINGS_CATEGORY; - const ActivePanel = SETTINGS_PANELS[renderedCategory]; - const content = ; + if (loading) return null; + const renderedCategory = resolveSettingsCategory( + activeCategory, + repoImagesEnabled, + hasPermission + ); + const ActivePanel = getSettingsPanel(renderedCategory); + const content = ( + + + + ); if (isMobile) { return ( diff --git a/packages/web/src/app/api/me/authorization/route.ts b/packages/web/src/app/api/me/authorization/route.ts new file mode 100644 index 000000000..33ea39ecc --- /dev/null +++ b/packages/web/src/app/api/me/authorization/route.ts @@ -0,0 +1,6 @@ +import { controlPlaneJsonGetProxy } from "@/lib/control-plane-json-proxy"; + +export const { GET } = controlPlaneJsonGetProxy( + () => "/me/authorization", + "current user authorization" +); diff --git a/packages/web/src/app/api/members/[userId]/role/route.ts b/packages/web/src/app/api/members/[userId]/role/route.ts new file mode 100644 index 000000000..e1df2734b --- /dev/null +++ b/packages/web/src/app/api/members/[userId]/role/route.ts @@ -0,0 +1,6 @@ +import { settingsProxy } from "@/lib/settings-proxy"; + +export const { PUT } = settingsProxy( + ({ userId }: { userId: string }) => `/members/${encodeURIComponent(userId)}/role`, + "member role" +); diff --git a/packages/web/src/app/api/members/[userId]/status/route.ts b/packages/web/src/app/api/members/[userId]/status/route.ts new file mode 100644 index 000000000..5085d3013 --- /dev/null +++ b/packages/web/src/app/api/members/[userId]/status/route.ts @@ -0,0 +1,6 @@ +import { settingsProxy } from "@/lib/settings-proxy"; + +export const { PUT } = settingsProxy( + ({ userId }: { userId: string }) => `/members/${encodeURIComponent(userId)}/status`, + "member status" +); diff --git a/packages/web/src/app/api/members/route.ts b/packages/web/src/app/api/members/route.ts new file mode 100644 index 000000000..ebf90baa5 --- /dev/null +++ b/packages/web/src/app/api/members/route.ts @@ -0,0 +1,3 @@ +import { settingsProxy } from "@/lib/settings-proxy"; + +export const { GET } = settingsProxy(() => "/members", "members"); diff --git a/packages/web/src/app/api/roles/[id]/route.ts b/packages/web/src/app/api/roles/[id]/route.ts new file mode 100644 index 000000000..c1aadbf3c --- /dev/null +++ b/packages/web/src/app/api/roles/[id]/route.ts @@ -0,0 +1,6 @@ +import { settingsProxy } from "@/lib/settings-proxy"; + +export const { GET } = settingsProxy( + ({ id }: { id: string }) => `/roles/${encodeURIComponent(id)}`, + "role" +); diff --git a/packages/web/src/app/api/roles/route.ts b/packages/web/src/app/api/roles/route.ts new file mode 100644 index 000000000..36fe74458 --- /dev/null +++ b/packages/web/src/app/api/roles/route.ts @@ -0,0 +1,3 @@ +import { settingsProxy } from "@/lib/settings-proxy"; + +export const { GET } = settingsProxy(() => "/roles", "roles"); diff --git a/packages/web/src/components/app-auth-boundary.test.tsx b/packages/web/src/components/app-auth-boundary.test.tsx index 80a2bfaa6..f627da84a 100644 --- a/packages/web/src/components/app-auth-boundary.test.tsx +++ b/packages/web/src/components/app-auth-boundary.test.tsx @@ -6,16 +6,33 @@ import * as matchers from "@testing-library/jest-dom/matchers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useAuthSession } from "@/lib/auth-session"; import { AppAuthBoundary } from "./app-auth-boundary"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; expect.extend(matchers); vi.mock("@/lib/auth-session", () => ({ useAuthSession: vi.fn(), })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: vi.fn(), +})); + +const activeAuthorization = { + userId: "11111111111111111111111111111111", + suspendedAt: null, + role: { id: "role_builtin_member", key: "member" as const, name: "Member" }, + permissions: ["repositories.read" as const], +}; afterEach(() => { cleanup(); vi.clearAllMocks(); + vi.mocked(useCurrentUserAuthorization).mockReturnValue({ + authorization: null, + loading: false, + error: null, + hasPermission: () => false, + }); }); describe("AppAuthBoundary", () => { @@ -24,6 +41,12 @@ describe("AppAuthBoundary", () => { data: { user: { id: "user-1", name: "Test User" } }, status: "authenticated", }); + vi.mocked(useCurrentUserAuthorization).mockReturnValue({ + authorization: activeAuthorization, + loading: false, + error: null, + hasPermission: () => true, + }); render(Session); @@ -59,6 +82,23 @@ describe("AppAuthBoundary", () => { expect(screen.queryByRole("link", { name: "Sign in" })).not.toBeInTheDocument(); }); + it("fails closed when workspace access is suspended", () => { + vi.mocked(useAuthSession).mockReturnValue({ + data: { user: { id: "user-1", name: "Test User" } }, + status: "authenticated", + }); + vi.mocked(useCurrentUserAuthorization).mockReturnValue({ + authorization: { ...activeAuthorization, suspendedAt: 1, permissions: [] }, + loading: false, + error: null, + hasPermission: () => false, + }); + + render(Session); + + expect(screen.getByRole("alert")).toHaveTextContent("Your workspace access is disabled."); + }); + it("fails closed for an unhandled authentication state", () => { vi.mocked(useAuthSession).mockReturnValue({ data: null, diff --git a/packages/web/src/components/app-auth-boundary.tsx b/packages/web/src/components/app-auth-boundary.tsx index 68c5e978b..0ae752692 100644 --- a/packages/web/src/components/app-auth-boundary.tsx +++ b/packages/web/src/components/app-auth-boundary.tsx @@ -5,9 +5,14 @@ import { useAuthSession } from "@/lib/auth-session"; import { APP_NAME } from "@/lib/site-config"; import { Button } from "@/components/ui/button"; import { ErrorBanner } from "@/components/ui/error-banner"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +/** + * Renders application children only for authenticated, active workspace users after authorization resolves. + */ export function AppAuthBoundary({ children }: { children: React.ReactNode }) { const { status } = useAuthSession(); + const { authorization, loading: authorizationLoading, error } = useCurrentUserAuthorization(); if (status === "loading") { return ( @@ -47,6 +52,31 @@ export function AppAuthBoundary({ children }: { children: React.ReactNode }) { } if (status === "authenticated") { + if (authorizationLoading) { + return ( +
+
+
+ ); + } + if (error || !authorization) { + return ( +
+ Authorization is temporarily unavailable. +
+ ); + } + if (authorization.suspendedAt !== null) { + return ( +
+ Your workspace access is disabled. +
+ ); + } return children; } diff --git a/packages/web/src/components/app-destinations.ts b/packages/web/src/components/app-destinations.ts index 4b78dd323..2e418cd7a 100644 --- a/packages/web/src/components/app-destinations.ts +++ b/packages/web/src/components/app-destinations.ts @@ -1,11 +1,21 @@ import { AutomationsIcon, DataControlsIcon, SettingsIcon } from "@/components/ui/icons"; +import type { PermissionId } from "@open-inspect/shared/rbac"; +import type { ComponentType } from "react"; + +export interface AppDestination { + label: string; + description: string; + href: string; + icon: ComponentType<{ className?: string }>; + requiredPermission?: PermissionId; +} export const SETTINGS_DESTINATION = { label: "Settings", description: "Configure Open Inspect", href: "/settings", icon: SettingsIcon, -} as const; +} as const satisfies AppDestination; export const PRIMARY_APP_DESTINATIONS = [ { @@ -13,13 +23,15 @@ export const PRIMARY_APP_DESTINATIONS = [ description: "Manage scheduled and event-triggered work", href: "/automations", icon: AutomationsIcon, + requiredPermission: "automations.read", }, { label: "Analytics", description: "View usage across sessions, repositories, and users", href: "/analytics", icon: DataControlsIcon, + requiredPermission: "analytics.read", }, -] as const; +] as const satisfies readonly AppDestination[]; export const APP_DESTINATIONS = [SETTINGS_DESTINATION, ...PRIMARY_APP_DESTINATIONS] as const; diff --git a/packages/web/src/components/global-command-menu.test.tsx b/packages/web/src/components/global-command-menu.test.tsx index c6e7baa6d..0a8531c62 100644 --- a/packages/web/src/components/global-command-menu.test.tsx +++ b/packages/web/src/components/global-command-menu.test.tsx @@ -15,7 +15,10 @@ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { value: vi.fn(), }); -const mocks = vi.hoisted(() => ({ repoImagesEnabled: true })); +const mocks = vi.hoisted(() => ({ + repoImagesEnabled: true, + allowedPermissions: null as Set | null, +})); vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ useKeyboardShortcuts: () => ({ labels: { "new-session": "Cmd/Ctrl+Shift+O" } }), @@ -25,6 +28,13 @@ vi.mock("@/lib/sandbox-provider", () => ({ supportsRepoImages: () => mocks.repoImagesEnabled, })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + mocks.allowedPermissions === null || mocks.allowedPermissions.has(permission), + }), +})); + beforeEach(() => { vi.stubGlobal( "ResizeObserver", @@ -39,6 +49,7 @@ beforeEach(() => { afterEach(() => { cleanup(); mocks.repoImagesEnabled = true; + mocks.allowedPermissions = null; vi.unstubAllGlobals(); }); @@ -70,6 +81,26 @@ describe("GlobalCommandMenu", () => { ).toBeInTheDocument(); }); + it("omits session creation destinations without session creation permission", () => { + mocks.allowedPermissions = new Set(); + + renderMenu(); + + expect(screen.queryByText("New session")).not.toBeInTheDocument(); + expect(screen.queryByText("Home")).not.toBeInTheDocument(); + expect(screen.queryByText("Start a coding session")).not.toBeInTheDocument(); + }); + + it("omits application destinations without their read permissions", () => { + mocks.allowedPermissions = new Set(); + + renderMenu(); + + expect(screen.queryByText("Automations")).not.toBeInTheDocument(); + expect(screen.queryByText("Analytics")).not.toBeInTheDocument(); + expect(screen.getByText("Configure Open Inspect")).toBeInTheDocument(); + }); + it("selects Analytics from the keyboard", async () => { const user = userEvent.setup(); const { onNavigate, onOpenChange } = renderMenu(); @@ -161,6 +192,27 @@ describe("GlobalCommandMenu", () => { expect(screen.queryByText("Images")).not.toBeInTheDocument(); }); + it("omits settings destinations the user cannot view", () => { + mocks.allowedPermissions = new Set(["models.preferences.manage"]); + renderMenu(); + + expect(screen.getByText("Appearance")).toBeInTheDocument(); + expect(screen.getByText("Models")).toBeInTheDocument(); + expect(screen.queryByText("Secrets")).not.toBeInTheDocument(); + }); + + it("requires repository read access for the repository secrets destination", () => { + mocks.allowedPermissions = new Set(["repositories.secrets.manage"]); + const { rerender, props } = renderMenu(); + + expect(screen.queryByText("Secrets")).not.toBeInTheDocument(); + + mocks.allowedPermissions.add("repositories.read"); + rerender(); + + expect(screen.getByText("Secrets")).toBeInTheDocument(); + }); + it("preserves order-independent session search", async () => { const user = userEvent.setup(); renderMenu([ diff --git a/packages/web/src/components/global-command-menu.tsx b/packages/web/src/components/global-command-menu.tsx index e341a7588..aa073671c 100644 --- a/packages/web/src/components/global-command-menu.tsx +++ b/packages/web/src/components/global-command-menu.tsx @@ -11,6 +11,7 @@ import { BranchIcon, PlusIcon } from "@/components/ui/icons"; import { AppIcon } from "@/components/ui/app-icon"; import { APP_DESTINATIONS } from "@/components/app-destinations"; import { getSettingsGroups } from "@/components/settings/settings-registry"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; import { Command, CommandDialog, @@ -72,6 +73,9 @@ function CommandMenuFooter() { ); } +/** + * Provides global navigation and search while exposing only settings destinations the user may access. + */ export function GlobalCommandMenu({ open, onOpenChange, @@ -80,11 +84,13 @@ export function GlobalCommandMenu({ sessions, }: GlobalCommandMenuProps) { const { labels } = useKeyboardShortcuts(); + const { hasPermission } = useCurrentUserAuthorization(); const searchableSessions = useMemo( () => sessions.filter((session) => session.status !== "archived"), [sessions] ); - const settingsGroups = getSettingsGroups(); + const settingsGroups = getSettingsGroups({ hasPermission }); + const canCreateSession = hasPermission("sessions.create"); const handleSelect = (callback: () => void) => { onOpenChange(false); @@ -92,21 +98,28 @@ export function GlobalCommandMenu({ }; const navigationItems = [ - { - label: "New session", - description: "Start a coding session", - Icon: PlusIcon, - onSelect: onNewSession, - shortcut: labels["new-session"], - }, - { - label: "Home", - description: "Ask a question or describe what you want to build", - Icon: AppIcon, - onSelect: () => onNavigate("/"), - shortcut: undefined, - }, - ...APP_DESTINATIONS.map(({ label, description, href, icon: Icon }) => ({ + ...(canCreateSession + ? [ + { + label: "New session", + description: "Start a coding session", + Icon: PlusIcon, + onSelect: onNewSession, + shortcut: labels["new-session"], + }, + { + label: "Home", + description: "Ask a question or describe what you want to build", + Icon: AppIcon, + onSelect: () => onNavigate("/"), + shortcut: undefined, + }, + ] + : []), + ...APP_DESTINATIONS.filter( + (destination) => + !("requiredPermission" in destination) || hasPermission(destination.requiredPermission) + ).map(({ label, description, href, icon: Icon }) => ({ label, description, Icon, diff --git a/packages/web/src/components/session-sidebar.test.tsx b/packages/web/src/components/session-sidebar.test.tsx index 2d2738a39..ecdcb57ee 100644 --- a/packages/web/src/components/session-sidebar.test.tsx +++ b/packages/web/src/components/session-sidebar.test.tsx @@ -8,8 +8,9 @@ import { SessionSidebar } from "./session-sidebar"; expect.extend(matchers); -const { mockHook } = vi.hoisted(() => ({ +const { mockHook, authorization } = vi.hoisted(() => ({ mockHook: vi.fn(), + authorization: { permissions: null as Set | null }, })); vi.mock("@/hooks/use-sidebar-sessions", () => ({ useSidebarSessions: mockHook })); @@ -19,6 +20,12 @@ vi.mock("@/lib/auth-session", () => ({ })); vi.mock("@/hooks/use-media-query", () => ({ useIsMobile: () => false })); vi.mock("@/hooks/use-environments", () => ({ useEnvironments: () => ({ environments: [] }) })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + authorization.permissions === null || authorization.permissions.has(permission), + }), +})); vi.mock("next/navigation", () => ({ usePathname: () => "/", useRouter: () => ({ push: vi.fn() }), @@ -60,6 +67,7 @@ const noPagination = { }; beforeEach(() => { + authorization.permissions = null; const attention = session("attention", "Needs review"); const running = session("running", "Implementing inbox"); const child = session("child", "Checking tests", running.id); @@ -101,6 +109,16 @@ describe("SessionSidebar", () => { expect(screen.getByRole("link", { name: "Analytics" })).toHaveAttribute("href", "/analytics"); }); + it("hides application destinations without their canonical read permission", () => { + authorization.permissions = new Set(["automations.read"]); + + render(); + + expect(screen.getByRole("link", { name: "Automations" })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Analytics" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /New session/ })).not.toBeInTheDocument(); + }); + it("renders server-classified sections and nested descendants", () => { render(); diff --git a/packages/web/src/components/session-sidebar.tsx b/packages/web/src/components/session-sidebar.tsx index a86fd705a..1394a1adc 100644 --- a/packages/web/src/components/session-sidebar.tsx +++ b/packages/web/src/components/session-sidebar.tsx @@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button"; import { useEnvironments } from "@/hooks/use-environments"; import { SessionWithChildren } from "@/components/session-with-children"; import { UserMenu } from "@/components/sidebar-user-menu"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; export type { SessionItem } from "@/hooks/use-sidebar-sessions"; @@ -72,6 +73,7 @@ export function SessionSidebar({ const pathname = usePathname(); const router = useRouter(); const isMobile = useIsMobile(); + const { hasPermission } = useCurrentUserAuthorization(); const currentSessionId = pathname?.startsWith("/session/") ? pathname.split("/")[2] : null; @@ -202,7 +204,7 @@ export function SessionSidebar({
- + {hasPermission("sessions.create") && } - {PRIMARY_APP_DESTINATIONS.map(({ href, label, icon: Icon }) => ( + {PRIMARY_APP_DESTINATIONS.filter((destination) => + hasPermission(destination.requiredPermission) + ).map(({ href, label, icon: Icon }) => ( ({ +const { toastMock, authorizationMock } = vi.hoisted(() => ({ toastMock: { success: vi.fn(), error: vi.fn(), }, + authorizationMock: { permissions: null as Set | null }, })); vi.mock("sonner", () => ({ toast: toastMock, })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + authorizationMock.permissions === null || authorizationMock.permissions.has(permission), + }), +})); + vi.mock("next/link", () => ({ default: ({ children, href, ...props }: React.ComponentProps<"a">) => ( @@ -143,9 +151,20 @@ afterEach(async () => { vi.restoreAllMocks(); toastMock.success.mockReset(); toastMock.error.mockReset(); + authorizationMock.permissions = null; }); describe("DataControlsSettings — unarchive flow", () => { + it("keeps archived sessions readable without exposing unarchive to read-only roles", async () => { + authorizationMock.permissions = new Set(["sessions.read"]); + installFetch({ archivedSessions: [createArchivedSession(1)] }); + + renderComponent(); + + expect(await screen.findByText("Session 1")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Unarchive" })).not.toBeInTheDocument(); + }); + it("removes the row when the unarchive request succeeds", async () => { installFetch({ archivedSessions: [createArchivedSession(1)], diff --git a/packages/web/src/components/settings/data-controls-settings.tsx b/packages/web/src/components/settings/data-controls-settings.tsx index 357957ecc..f8eab3002 100644 --- a/packages/web/src/components/settings/data-controls-settings.tsx +++ b/packages/web/src/components/settings/data-controls-settings.tsx @@ -17,6 +17,8 @@ import { } from "@/lib/session-list"; import { formatRelativeTime } from "@/lib/time"; import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { canUseSettingsCapability } from "./settings-registry"; const PAGE_SIZE = 20; const ARCHIVED_SESSIONS_KEY = buildSessionsPageKey({ @@ -26,6 +28,12 @@ const ARCHIVED_SESSIONS_KEY = buildSessionsPageKey({ }); export function DataControlsSettings() { + const { hasPermission } = useCurrentUserAuthorization(); + const canUnarchive = canUseSettingsCapability( + "data-controls", + "unarchiveSessions", + hasPermission + ); const [extraSessions, setExtraSessions] = useState([]); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(false); @@ -126,6 +134,7 @@ export function DataControlsSettings() { ))} @@ -150,9 +159,11 @@ export function DataControlsSettings() { function ArchivedSessionRow({ session, + canUnarchive, onUnarchive, }: { session: SessionListItem; + canUnarchive: boolean; onUnarchive: (id: string) => void; }) { const repoInfo = formatRepoLabel(session.repoOwner, session.repoName); @@ -169,14 +180,16 @@ function ArchivedSessionRow({ {repoInfo}
- + {canUnarchive && ( + + )} ); } diff --git a/packages/web/src/components/settings/environment-integration-settings.tsx b/packages/web/src/components/settings/environment-integration-settings.tsx index f63681b53..77061aefd 100644 --- a/packages/web/src/components/settings/environment-integration-settings.tsx +++ b/packages/web/src/components/settings/environment-integration-settings.tsx @@ -38,9 +38,11 @@ const ENABLEMENT_CHOICES: Array<{ value: EnablementChoice; label: string }> = [ export function EnvironmentIntegrationSettings({ environmentId, repositories, + canManage, }: { environmentId: string; repositories: EnvironmentRepository[]; + canManage: boolean; }) { const primary = repositories[0]; const primaryLabel = primary @@ -54,36 +56,38 @@ export function EnvironmentIntegrationSettings({ left unset inherits from {primaryLabel}'s settings.

- - - -
-

Sandbox

-

- Inherited values are shown as the current settings; saving only pins the fields you - change. -

- + -
+ + +
+

Sandbox

+

+ Inherited values are shown as the current settings; saving only pins the fields you + change. +

+ +
+ ); } diff --git a/packages/web/src/components/settings/environments-settings.tsx b/packages/web/src/components/settings/environments-settings.tsx index 8949d5abe..2ed017607 100644 --- a/packages/web/src/components/settings/environments-settings.tsx +++ b/packages/web/src/components/settings/environments-settings.tsx @@ -21,16 +21,28 @@ import { EnvironmentIntegrationSettings } from "./environment-integration-settin import { EnvironmentSecretsImport } from "./environment-secrets-import"; import { ImageBuildStatus } from "./image-build-status"; import { SecretsEditor } from "@/components/secrets-editor"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; type View = | { mode: "list" } | { mode: "create" } | { mode: "edit"; environmentId: string; tab: "configuration" | "secrets" | "overrides" }; +/** + * Presents environments with configuration, secrets, settings, and image actions gated independently by permission. + */ export function EnvironmentsSettings() { + const { hasPermission } = useCurrentUserAuthorization(); + const canManage = hasPermission("environments.manage"); + const canManageSecrets = hasPermission("environments.secrets.manage"); + const canManageRepoSecrets = hasPermission("repositories.secrets.manage"); + const canManageSettings = hasPermission("environments.settings.manage"); + const canManageImages = hasPermission("environments.images.manage"); + const canReadImages = hasPermission("image_builds.read"); + const canReadSettings = hasPermission("integrations.read"); const { environments, loading } = useEnvironments(); const { data: imageBuildsFeed, error: imageBuildsError } = useImageBuilds( - environments.some((environment) => environment.prebuildEnabled) + canReadImages && environments.some((environment) => environment.prebuildEnabled) ); const [view, setView] = useState({ mode: "list" }); const [submitting, setSubmitting] = useState(false); @@ -208,20 +220,27 @@ export function EnvironmentsSettings() {

- {(["configuration", "secrets", "overrides"] as const).map((tab) => ( - - ))} + {(["configuration", "secrets", "overrides"] as const) + .filter( + (tab) => + (tab === "configuration" && canManage) || + (tab === "secrets" && canManageSecrets) || + (tab === "overrides" && canReadSettings) + ) + .map((tab) => ( + + ))}
{error && {error}} @@ -242,10 +261,12 @@ export function EnvironmentsSettings() { and triggers a rebuild.

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

Named repository sets that launch together in one workspace, with their own secrets @@ -319,82 +343,100 @@ export function EnvironmentsSettings() {

- {prebuildsSupported && ( + {prebuildsSupported && (canReadImages || canManage || canManageImages) && ( <> - - image.scopeKind === "environment" && image.scopeId === environment.id - )} - feedUnavailable={Boolean(imageBuildsError) && !imageBuildsFeed} - /> - - - - - handlePrebuildToggle(environment, checked) - } - disabled={isToggling} - aria-label={`Toggle prebuilt images for ${environment.name}`} - /> - - - Prebuild images - - + )} + {canManage && ( + + + + + handlePrebuildToggle(environment, checked) + } + disabled={isToggling} + aria-label={`Toggle prebuilt images for ${environment.name}`} + /> + + + Prebuild images + + )} + {canManageImages && ( + + )} )} - - {confirmDeleteId === environment.id ? ( -
- - -
- ) : ( + {(canManage || canManageSecrets || canReadSettings) && ( )} + {canManage && + (confirmDeleteId === environment.id ? ( +
+ + +
+ ) : ( + + ))}
diff --git a/packages/web/src/components/settings/images-settings.test.tsx b/packages/web/src/components/settings/images-settings.test.tsx index 9adc1e180..980fe7b2a 100644 --- a/packages/web/src/components/settings/images-settings.test.tsx +++ b/packages/web/src/components/settings/images-settings.test.tsx @@ -11,6 +11,15 @@ import { ImagesSettings } from "./images-settings"; expect.extend(matchers); +const mocks = vi.hoisted(() => ({ allowedPermissions: null as Set | null })); + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + mocks.allowedPermissions === null || mocks.allowedPermissions.has(permission), + }), +})); + vi.mock("@/hooks/use-repos", () => ({ useRepos: () => ({ repos: [ @@ -52,6 +61,7 @@ function renderWithFeed(feed: ImageBuildsFeed) { afterEach(() => { cleanup(); vi.restoreAllMocks(); + mocks.allowedPermissions = null; }); describe("ImagesSettings", () => { @@ -127,6 +137,20 @@ describe("ImagesSettings", () => { ).not.toBeChecked(); }); + it("keeps image state visible but disables mutations for a read-only role", () => { + mocks.allowedPermissions = new Set(["image_builds.read"]); + renderWithFeed({ + units: [], + enabledRepos: [{ repoOwner: "acme", repoName: "web" }], + images: [], + }); + + expect( + screen.getByRole("switch", { name: "Toggle pre-built images for acme/web" }) + ).toBeDisabled(); + expect(screen.queryByTitle("Rebuild image")).not.toBeInTheDocument(); + }); + it("shows an error instead of unchecked toggles when the feed fails", async () => { render( handleToggle(repo.owner, repo.name, checked)} - disabled={isToggling} + disabled={!canManage || isToggling} aria-label={`Toggle pre-built images for ${repo.owner}/${repo.name}`} /> @@ -180,15 +186,17 @@ export function ImagesSettings() { } } /> - + {canManage && ( + + )} ); diff --git a/packages/web/src/components/settings/integrations/commit-signing-settings.test.tsx b/packages/web/src/components/settings/integrations/commit-signing-settings.test.tsx index 7c0c11f69..24ce403b4 100644 --- a/packages/web/src/components/settings/integrations/commit-signing-settings.test.tsx +++ b/packages/web/src/components/settings/integrations/commit-signing-settings.test.tsx @@ -8,6 +8,10 @@ import * as matchers from "@testing-library/jest-dom/matchers"; import { CommitSigningSettings } from "./commit-signing-settings"; +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ hasPermission: () => true }), +})); + expect.extend(matchers); const { useSWRMock, mutateMock } = vi.hoisted(() => ({ diff --git a/packages/web/src/components/settings/integrations/commit-signing-settings.tsx b/packages/web/src/components/settings/integrations/commit-signing-settings.tsx index b3564457f..9271e8ca5 100644 --- a/packages/web/src/components/settings/integrations/commit-signing-settings.tsx +++ b/packages/web/src/components/settings/integrations/commit-signing-settings.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; const SETTINGS_KEY = "/api/commit-signing"; @@ -22,7 +23,12 @@ const STATUS_LABELS: Record = { enabled: "Configured", }; +/** + * Displays commit-signing settings and makes the configuration read-only without management permission. + */ export function CommitSigningSettings() { + const { hasPermission } = useCurrentUserAuthorization(); + const canManage = hasPermission("commit_signing.manage"); const { data: rawData, error, isLoading, mutate } = useSWR(SETTINGS_KEY); const viewState = useMemo(() => { if (isLoading) return { kind: "loading" } as const; @@ -140,7 +146,7 @@ export function CommitSigningSettings() { )} -
+
- + ); } diff --git a/packages/web/src/components/settings/integrations/enablement-integration-settings.test.tsx b/packages/web/src/components/settings/integrations/enablement-integration-settings.test.tsx index 4c1c76478..25f376f0a 100644 --- a/packages/web/src/components/settings/integrations/enablement-integration-settings.test.tsx +++ b/packages/web/src/components/settings/integrations/enablement-integration-settings.test.tsx @@ -9,6 +9,15 @@ import type { EnrichedRepository } from "@open-inspect/shared/types/repository-c import { CodeServerIntegrationSettings } from "./code-server-integration-settings"; import { VncIntegrationSettings } from "./vnc-integration-settings"; +const allowedPermissions = vi.hoisted(() => ({ value: null as Set | null })); + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + allowedPermissions.value === null || allowedPermissions.value.has(permission), + }), +})); + expect.extend(matchers); const { useSWRMock, mutateMock } = vi.hoisted(() => ({ @@ -103,6 +112,7 @@ beforeEach(() => { toastError.mockReset(); mutateMock.mockReset(); useSWRMock.mockReset(); + allowedPermissions.value = null; vi.stubGlobal("fetch", fetchMock); }); @@ -135,6 +145,21 @@ describe("code-server enablement integration settings", () => { ); }); + it("separates global and repository mutation permissions", () => { + setupSWR(id, { + global: { defaults: { enabled: true } }, + repos: [{ repo: nestedRepo, settings: { enabled: true } }], + }); + allowedPermissions.value = new Set(["integrations.read", "repositories.settings.manage"]); + + render(); + + expect(screen.getByRole("checkbox", { name: new RegExp(`^${enableLabel}`) })).toBeDisabled(); + expect( + within(overrideRow(nestedRepo)).getByRole("checkbox", { name: /enabled/i }) + ).toBeEnabled(); + }); + it("resets global settings", async () => { const user = userEvent.setup(); setupSWR(id, { global: { defaults: { enabled: true } } }); diff --git a/packages/web/src/components/settings/integrations/enablement-integration-settings.tsx b/packages/web/src/components/settings/integrations/enablement-integration-settings.tsx index e6b322a08..9ce6d7969 100644 --- a/packages/web/src/components/settings/integrations/enablement-integration-settings.tsx +++ b/packages/web/src/components/settings/integrations/enablement-integration-settings.tsx @@ -31,6 +31,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; interface EnablementSettings { enabled?: boolean; @@ -70,7 +71,13 @@ interface ReposResponse { repos: EnrichedRepository[]; } +/** + * Renders global and repository enablement settings with each scope editable only by authorized users. + */ export function EnablementIntegrationSettings({ copy }: { copy: EnablementIntegrationCopy }) { + const { hasPermission } = useCurrentUserAuthorization(); + const canManageGlobal = hasPermission("integrations.manage"); + const canManageRepos = hasPermission("repositories.settings.manage"); const globalSettingsKey = `/api/integration-settings/${copy.id}` as const; const repoSettingsKey = `/api/integration-settings/${copy.id}/repos` as const; const { data: globalData, isLoading: globalLoading } = useSWR(globalSettingsKey); @@ -91,20 +98,24 @@ export function EnablementIntegrationSettings({ copy }: { copy: EnablementIntegr

{copy.title}

{copy.intro}

- - - - + + + + +
+ +
); diff --git a/packages/web/src/components/settings/integrations/github-integration-settings.test.tsx b/packages/web/src/components/settings/integrations/github-integration-settings.test.tsx index b63475b70..5ab884cb5 100644 --- a/packages/web/src/components/settings/integrations/github-integration-settings.test.tsx +++ b/packages/web/src/components/settings/integrations/github-integration-settings.test.tsx @@ -13,6 +13,10 @@ import { } from "@open-inspect/shared/types/integrations"; import { GitHubIntegrationSettings } from "./github-integration-settings"; +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ hasPermission: () => true }), +})); + expect.extend(matchers); interface RepoSettingsEntry { diff --git a/packages/web/src/components/settings/integrations/github-integration-settings.tsx b/packages/web/src/components/settings/integrations/github-integration-settings.tsx index c78c39006..5756e26d8 100644 --- a/packages/web/src/components/settings/integrations/github-integration-settings.tsx +++ b/packages/web/src/components/settings/integrations/github-integration-settings.tsx @@ -12,6 +12,7 @@ import { CommitSigningSettings } from "./commit-signing-settings"; import { GlobalSettingsSection } from "./github-global-settings-section"; import { RepoOverridesSection, type RepoSettingsEntry } from "./github-repo-overrides-section"; import { IntegrationSettingsSection } from "./integration-settings-section"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; const GLOBAL_SETTINGS_KEY = "/api/integration-settings/github"; const REPO_SETTINGS_KEY = "/api/integration-settings/github/repos"; @@ -28,7 +29,13 @@ interface ReposResponse { repos: EnrichedRepository[]; } +/** + * Displays GitHub integration settings with global and repository edits gated by their respective permissions. + */ export function GitHubIntegrationSettings() { + const { hasPermission } = useCurrentUserAuthorization(); + const canManageGlobal = hasPermission("integrations.manage"); + const canManageRepos = hasPermission("repositories.settings.manage"); const { data: globalData, isLoading: globalLoading } = useSWR(GLOBAL_SETTINGS_KEY); const { data: repoSettingsData, isLoading: repoSettingsLoading } = @@ -74,23 +81,27 @@ export function GitHubIntegrationSettings() { - +
+ +
- +
+ +
); diff --git a/packages/web/src/components/settings/integrations/linear-integration-settings.tsx b/packages/web/src/components/settings/integrations/linear-integration-settings.tsx index eb27e3ecd..91c53664d 100644 --- a/packages/web/src/components/settings/integrations/linear-integration-settings.tsx +++ b/packages/web/src/components/settings/integrations/linear-integration-settings.tsx @@ -46,6 +46,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { ModelReasoningDefaultsFields } from "./model-reasoning-defaults-fields"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; const GLOBAL_SETTINGS_KEY = "/api/integration-settings/linear"; const REPO_SETTINGS_KEY = "/api/integration-settings/linear/repos"; @@ -67,7 +68,13 @@ interface ReposResponse { repos: EnrichedRepository[]; } +/** + * Displays Linear integration settings with global and repository edits gated by their respective permissions. + */ export function LinearIntegrationSettings() { + const { hasPermission } = useCurrentUserAuthorization(); + const canManageGlobal = hasPermission("integrations.manage"); + const canManageRepos = hasPermission("repositories.settings.manage"); const { data: globalData, isLoading: globalLoading } = useSWR(GLOBAL_SETTINGS_KEY); const { data: repoSettingsData, isLoading: repoSettingsLoading } = @@ -108,21 +115,25 @@ export function LinearIntegrationSettings() { )}
- +
+ +
- +
+ +
); diff --git a/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx b/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx index 21b2d5829..b9cdf21ee 100644 --- a/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx +++ b/packages/web/src/components/settings/integrations/slack-integration-settings.test.tsx @@ -14,6 +14,10 @@ import { } from "@open-inspect/shared/types/integrations"; import { SlackIntegrationSettings } from "./slack-integration-settings"; +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ hasPermission: () => true }), +})); + expect.extend(matchers); interface RepoSettingsEntry { diff --git a/packages/web/src/components/settings/integrations/slack-integration-settings.tsx b/packages/web/src/components/settings/integrations/slack-integration-settings.tsx index b78d378ee..51db92585 100644 --- a/packages/web/src/components/settings/integrations/slack-integration-settings.tsx +++ b/packages/web/src/components/settings/integrations/slack-integration-settings.tsx @@ -53,6 +53,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; const GLOBAL_SETTINGS_KEY = "/api/integration-settings/slack"; const REPO_SETTINGS_KEY = "/api/integration-settings/slack/repos"; @@ -114,7 +115,13 @@ function mergedGlobalDefaults( return defaults; } +/** + * Displays Slack integration settings with global and repository edits gated by their respective permissions. + */ export function SlackIntegrationSettings() { + const { hasPermission } = useCurrentUserAuthorization(); + const canManageGlobal = hasPermission("integrations.manage"); + const canManageRepos = hasPermission("repositories.settings.manage"); const { data: globalData, isLoading: globalLoading } = useSWR(GLOBAL_SETTINGS_KEY); const { data: repoSettingsData, isLoading: repoSettingsLoading } = @@ -154,21 +161,27 @@ export function SlackIntegrationSettings() {

- - - +
+ +
+ +
+ +
- +
+ +
); diff --git a/packages/web/src/components/settings/mcp-servers-settings.test.tsx b/packages/web/src/components/settings/mcp-servers-settings.test.tsx index c59cb9a8d..0df7e49f7 100644 --- a/packages/web/src/components/settings/mcp-servers-settings.test.tsx +++ b/packages/web/src/components/settings/mcp-servers-settings.test.tsx @@ -13,6 +13,7 @@ expect.extend(matchers); const mocks = vi.hoisted(() => ({ mutate: vi.fn(), updateMcpServer: vi.fn(), + allowedPermissions: null as Set | null, })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); @@ -25,6 +26,12 @@ vi.mock("@/hooks/use-mcp-servers", () => ({ updateMcpServer: mocks.updateMcpServer, deleteMcpServer: vi.fn(), })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + mocks.allowedPermissions === null || mocks.allowedPermissions.has(permission), + }), +})); const servers: McpServerMetadata[] = [ { @@ -54,9 +61,22 @@ const servers: McpServerMetadata[] = [ afterEach(() => { cleanup(); vi.clearAllMocks(); + mocks.allowedPermissions = null; }); describe("McpServersSettings", () => { + it("shows servers but no mutation entry points with read-only permission", () => { + mocks.allowedPermissions = new Set(["mcp_servers.read"]); + + render(); + + expect(screen.getByText("Server A")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Add Server" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Server A/ })).toBeDisabled(); + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); + }); + it("does not close a newer draft when an older save completes", async () => { let resolveSave!: (server: McpServerMetadata) => void; mocks.updateMcpServer.mockReturnValue( diff --git a/packages/web/src/components/settings/mcp-servers-settings.tsx b/packages/web/src/components/settings/mcp-servers-settings.tsx index d8bf0fd75..594042369 100644 --- a/packages/web/src/components/settings/mcp-servers-settings.tsx +++ b/packages/web/src/components/settings/mcp-servers-settings.tsx @@ -32,6 +32,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; type ScopeMode = "global" | "selected"; type Editor = @@ -438,7 +439,12 @@ function McpServerForm({ ); } +/** + * Lists workspace MCP servers and exposes create, edit, and delete controls only to authorized users. + */ export function McpServersSettings() { + const { hasPermission } = useCurrentUserAuthorization(); + const canManage = hasPermission("mcp_servers.manage"); const { servers, loading, mutate } = useMcpServers(); const { repos, loading: loadingRepos } = useRepos(); const [editor, setEditor] = useState(null); @@ -564,12 +570,14 @@ export function McpServersSettings() {

MCP Servers

- + {canManage && ( + + )}

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

- handleToggle(server)} - aria-label={server.enabled ? "Disable" : "Enable"} - /> - -
+ {canManage && ( +
+ handleToggle(server)} + aria-label={server.enabled ? "Disable" : "Enable"} + /> + +
+ )}
{/* Expanded edit form */} @@ -713,7 +724,7 @@ export function McpServersSettings() { )} {/* Delete confirmation dialog */} - setDeleteTarget(null)}> + setDeleteTarget(null)}> Delete MCP server diff --git a/packages/web/src/components/settings/provider-accounts-settings.test.tsx b/packages/web/src/components/settings/provider-accounts-settings.test.tsx index 763fd18dc..28fb17384 100644 --- a/packages/web/src/components/settings/provider-accounts-settings.test.tsx +++ b/packages/web/src/components/settings/provider-accounts-settings.test.tsx @@ -43,6 +43,14 @@ const account = { }; let accountsResult: ModelProviderAccount[]; let defaultsResult: ModelProviderAccountDefault[]; +let allowedPermissions: Set | null; + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + allowedPermissions === null || allowedPermissions.has(permission), + }), +})); vi.mock("@/hooks/use-provider-accounts", () => ({ useProviderAccounts: () => ({ @@ -88,6 +96,7 @@ describe("ProviderAccountsSettings", () => { reconnectAccount.mockResolvedValue(undefined); accountsResult = [account]; defaultsResult = []; + allowedPermissions = null; Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) }, @@ -98,6 +107,18 @@ describe("ProviderAccountsSettings", () => { }; }); + it("keeps read-only account details visible without management actions", () => { + allowedPermissions = new Set(["provider_accounts.read"]); + + render(); + + expect(screen.getByText("Team ChatGPT")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Add account" })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "More actions for Team ChatGPT" }) + ).not.toBeInTheDocument(); + }); + it("reconnects OpenAI through device authorization with the selected account id", async () => { render(); fireEvent.pointerDown(screen.getByRole("button", { name: "More actions for Team ChatGPT" }), { diff --git a/packages/web/src/components/settings/provider-accounts-settings.tsx b/packages/web/src/components/settings/provider-accounts-settings.tsx index 0a7c76498..d34fbb1d6 100644 --- a/packages/web/src/components/settings/provider-accounts-settings.tsx +++ b/packages/web/src/components/settings/provider-accounts-settings.tsx @@ -54,6 +54,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; type Confirm = { account: ModelProviderAccount; action: "disable" | "archive" } | null; type Connection = @@ -174,7 +175,12 @@ function LegacyReconnectForm({ ); } +/** + * Displays provider accounts while restricting connection and account-management actions by permission. + */ export function ProviderAccountsSettings() { + const { hasPermission } = useCurrentUserAuthorization(); + const canManage = hasPermission("provider_accounts.manage"); const { providers, accounts, defaults, loading, error, refresh } = useProviderAccounts(); const legacyCredentials = useLegacyProviderCredentials(); const [connection, setConnection] = useState(null); @@ -256,32 +262,34 @@ export function ProviderAccountsSettings() {

Connected accounts

- - - - - - Subscriptions - {providers.map((provider) => ( - - beginConnection(CONNECTION_STRATEGIES[provider.provider].add()) - } - > - - {provider.subscriptionName} - - ))} - - + {canManage && ( + + + + + + Subscriptions + {providers.map((provider) => ( + + beginConnection(CONNECTION_STRATEGIES[provider.provider].add()) + } + > + + {provider.subscriptionName} + + ))} + + + )}
{accounts.length === 0 ? ( @@ -356,135 +364,139 @@ export function ProviderAccountsSettings() { -
- {account.status === "reconnect_required" && ( - - )} - {account.status === "disabled" && ( - - )} - - + {canManage && ( +
+ {account.status === "reconnect_required" && ( - - - {account.status !== "reconnect_required" && ( - - beginConnection( - CONNECTION_STRATEGIES[account.provider].reconnect(account) - ) - } - > - Reconnect - - )} - + )} + {account.status === "disabled" && ( + + + + {account.status !== "reconnect_required" && ( + + beginConnection( + CONNECTION_STRATEGIES[account.provider].reconnect(account) + ) + } + > + Reconnect + + )} + void run( - () => - setProviderAccountDefault( - account.provider, - account.id, - providerDefault?.unattendedMode ?? "provider_account" - ), - "Default updated" + () => runProviderAccountAction(account.id, "verify"), + "Account verified" ) } > - Make default + Verify - )} - { - if (operationInFlightRef.current) return; - const displayName = window - .prompt("Account name", account.displayName) - ?.trim(); - if (displayName) - void run( - () => renameProviderAccount(account.id, displayName), - "Account renamed" - ); - }} - > - Rename - - {externalAccountId && ( + {account.status === "active" && !isDefault && ( + + void run( + () => + setProviderAccountDefault( + account.provider, + account.id, + providerDefault?.unattendedMode ?? "provider_account" + ), + "Default updated" + ) + } + > + Make default + + )} - void navigator.clipboard - .writeText(externalAccountId) - .then(() => toast.success("Account ID copied")) - .catch(() => toast.error("Failed to copy account ID")) - } + disabled={saving} + onSelect={() => { + if (operationInFlightRef.current) return; + const displayName = window + .prompt("Account name", account.displayName) + ?.trim(); + if (displayName) + void run( + () => renameProviderAccount(account.id, displayName), + "Account renamed" + ); + }} > - Copy account ID + Rename - )} - - {account.status === "active" && ( + {externalAccountId && ( + + void navigator.clipboard + .writeText(externalAccountId) + .then(() => toast.success("Account ID copied")) + .catch(() => toast.error("Failed to copy account ID")) + } + > + Copy account ID + + )} + + {account.status === "active" && ( + + beginConfirmation({ account, action: "disable" }) + } + > + Disable + + )} beginConfirmation({ account, action: "disable" })} + onSelect={() => beginConfirmation({ account, action: "archive" })} > - Disable + Archive - )} - beginConfirmation({ account, action: "archive" })} - > - Archive - - - -
+ +
+
+ )} {account.status !== "active" && (

+ void mutate(member.userId, () => + updateMember(member, { kind: "role", roleId: event.target.value }) + ) + } + className="rounded border border-border bg-background px-2 py-1.5 text-sm" + > + {roles + .filter( + (role) => role.key !== "owner" || canTransfer || role.id === member.role.id + ) + .map((role) => ( + + ))} + + ) : ( + {member.role.name} + )} + + + ))} + +

+ )} + + {canReadRoles && ( +
+

+ Roles +

+
+ {roles.map((role) => ( +
+
+

{role.name}

+ + {role.assignmentCount} assigned + +
+

+ {role.description ?? `${role.permissions.length} permissions`} +

+
+ ))} +
+
+ )} + + ); +} diff --git a/packages/web/src/hooks/use-current-user-authorization.test.tsx b/packages/web/src/hooks/use-current-user-authorization.test.tsx new file mode 100644 index 000000000..a8c087543 --- /dev/null +++ b/packages/web/src/hooks/use-current-user-authorization.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom + +import { renderHook, waitFor } from "@testing-library/react"; +import { SWRConfig } from "swr"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +import { useAuthSession } from "@/lib/auth-session"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { useCurrentUserAuthorization } from "./use-current-user-authorization"; + +vi.mock("@/lib/auth-session", () => ({ useAuthSession: vi.fn() })); +vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() })); + +const authorizations = { + owner: { + userId: "11111111111111111111111111111111", + suspendedAt: null, + role: { id: "role_builtin_owner", key: "owner" as const, name: "Owner" }, + permissions: ["workspace.transfer_ownership" as const], + }, + member: { + userId: "22222222222222222222222222222222", + suspendedAt: null, + role: { id: "role_builtin_member", key: "member" as const, name: "Member" }, + permissions: ["repositories.read" as const], + }, +}; + +describe("useCurrentUserAuthorization", () => { + beforeEach(() => vi.clearAllMocks()); + + it("does not reuse cached authorization after the authenticated user changes", async () => { + let currentUser: keyof typeof authorizations = "owner"; + vi.mocked(useAuthSession).mockImplementation( + () => + ({ + status: "authenticated", + data: { user: { id: authorizations[currentUser].userId } }, + }) as ReturnType + ); + vi.mocked(browserApiFetch).mockImplementation(async () => + Response.json(authorizations[currentUser]) + ); + const wrapper = ({ children }: { children: ReactNode }) => ( + new Map(), dedupingInterval: 0 }}>{children} + ); + const { result, rerender } = renderHook(useCurrentUserAuthorization, { wrapper }); + await waitFor(() => expect(result.current.authorization?.role.key).toBe("owner")); + const ownerHasPermission = result.current.hasPermission; + rerender(); + expect(result.current.hasPermission).toBe(ownerHasPermission); + + currentUser = "member"; + rerender(); + + await waitFor(() => expect(result.current.authorization?.role.key).toBe("member")); + expect(result.current.hasPermission).not.toBe(ownerHasPermission); + expect(result.current.hasPermission("workspace.transfer_ownership")).toBe(false); + }); +}); diff --git a/packages/web/src/hooks/use-current-user-authorization.ts b/packages/web/src/hooks/use-current-user-authorization.ts new file mode 100644 index 000000000..8eb4204fe --- /dev/null +++ b/packages/web/src/hooks/use-current-user-authorization.ts @@ -0,0 +1,53 @@ +"use client"; + +import useSWR from "swr"; +import { + effectiveAuthorizationSchema, + type EffectiveAuthorization, + type PermissionId, +} from "@open-inspect/shared/rbac"; +import { useAuthSession } from "@/lib/auth-session"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { useCallback } from "react"; + +/** Endpoint key for the signed-in user's effective workspace authorization. */ +export const CURRENT_USER_AUTHORIZATION_KEY = "/api/me/authorization" as const; + +/** Returns the user-scoped cache key for effective workspace authorization. */ +export function currentUserAuthorizationKey(userId: string) { + return [CURRENT_USER_AUTHORIZATION_KEY, userId] as const; +} + +async function fetchAuthorization(): Promise { + const response = await browserApiFetch(CURRENT_USER_AUTHORIZATION_KEY); + if (!response.ok) throw new Error(`Authorization request failed (${response.status})`); + return effectiveAuthorizationSchema.parse(await response.json()); +} + +/** + * Provides the signed-in user's effective permissions, denying permission checks until they load. + */ +export function useCurrentUserAuthorization(): { + authorization: EffectiveAuthorization | null; + loading: boolean; + error: unknown; + hasPermission: (permission: PermissionId) => boolean; +} { + const { data: session, status } = useAuthSession(); + const userId = session?.user?.id; + const { data, isLoading, error } = useSWR( + status === "authenticated" && userId ? currentUserAuthorizationKey(userId) : null, + fetchAuthorization + ); + const hasPermission = useCallback( + (permission: PermissionId) => data?.permissions.includes(permission) ?? false, + [data?.permissions] + ); + + return { + authorization: data ?? null, + loading: status === "authenticated" && isLoading, + error, + hasPermission, + }; +} diff --git a/packages/web/src/hooks/use-provider-accounts.test.tsx b/packages/web/src/hooks/use-provider-accounts.test.tsx index 66e9b2c17..e31b3dbba 100644 --- a/packages/web/src/hooks/use-provider-accounts.test.tsx +++ b/packages/web/src/hooks/use-provider-accounts.test.tsx @@ -18,12 +18,21 @@ import { useProviderAccounts, } from "./use-provider-accounts"; -vi.mock("@/lib/auth-session", () => ({ - useAuthSession: () => ({ data: { user: { id: "user-1" } }, status: "authenticated" }), +const permissions = vi.hoisted(() => new Set()); + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => permissions.has(permission), + }), })); vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() })); +beforeEach(() => { + permissions.clear(); + permissions.add("provider_accounts.read"); +}); + function wrapper({ children }: { children: ReactNode }) { return ( new Map(), dedupingInterval: 0 }}>{children} @@ -91,6 +100,34 @@ describe("useLegacyProviderCredentials", () => { describe("useProviderAccounts", () => { beforeEach(() => vi.clearAllMocks()); + it("does not request provider resources without read permission", () => { + permissions.clear(); + + const { result } = renderHook( + () => ({ accounts: useProviderAccounts(), legacy: useLegacyProviderCredentials() }), + { wrapper } + ); + + expect(browserApiFetch).not.toHaveBeenCalled(); + expect(result.current.accounts).toMatchObject({ accounts: [], defaults: [], loading: false }); + expect(result.current.legacy).toMatchObject({ legacyKeys: [], loading: false }); + }); + + it("clears provider resources when read permission is revoked", async () => { + vi.mocked(browserApiFetch) + .mockResolvedValueOnce(Response.json({ accounts: [account] })) + .mockResolvedValueOnce(Response.json({ defaults: [] })); + + const { result, rerender } = renderHook(() => useProviderAccounts(), { wrapper }); + await waitFor(() => expect(result.current.accounts).toEqual([account])); + + permissions.clear(); + rerender(); + + expect(result.current).toMatchObject({ accounts: [], defaults: [], loading: false }); + expect(browserApiFetch).toHaveBeenCalledTimes(2); + }); + it("uses the shared static provider catalog without fetching it", async () => { vi.mocked(browserApiFetch) .mockResolvedValueOnce(Response.json({ accounts: [] })) diff --git a/packages/web/src/hooks/use-provider-accounts.ts b/packages/web/src/hooks/use-provider-accounts.ts index 5c2a2348c..b281221f9 100644 --- a/packages/web/src/hooks/use-provider-accounts.ts +++ b/packages/web/src/hooks/use-provider-accounts.ts @@ -1,6 +1,6 @@ import useSWR from "swr"; import { z, type ZodType } from "zod"; -import { useAuthSession } from "@/lib/auth-session"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; import { modelProviderAccountDefaultsResponseSchema, @@ -89,11 +89,12 @@ async function requestProviderResourceWithoutContent( } export function useProviderAccounts() { - const { data: session } = useAuthSession(); - const accounts = useSWR(session ? ACCOUNTS_KEY : null, async (path) => { + const { hasPermission } = useCurrentUserAuthorization(); + const canRead = hasPermission("provider_accounts.read"); + const accounts = useSWR(canRead ? ACCOUNTS_KEY : null, async (path) => { return (await requestProviderResource(path, modelProviderAccountsResponseSchema)).accounts; }); - const defaults = useSWR(session ? DEFAULTS_KEY : null, async (path) => { + const defaults = useSWR(canRead ? DEFAULTS_KEY : null, async (path) => { return (await requestProviderResource(path, modelProviderAccountDefaultsResponseSchema)) .defaults; }); @@ -112,9 +113,10 @@ export function useProviderAccounts() { } export function useLegacyProviderCredentials() { - const { data: session } = useAuthSession(); + const { hasPermission } = useCurrentUserAuthorization(); + const canRead = hasPermission("provider_accounts.read"); const result = useSWR( - session ? LEGACY_CREDENTIALS_KEY : null, + canRead ? LEGACY_CREDENTIALS_KEY : null, async (path: BrowserApiPath) => { return requestProviderResource(path, legacyProviderCredentialsResponseSchema); } diff --git a/packages/web/src/hooks/use-repos.test.tsx b/packages/web/src/hooks/use-repos.test.tsx new file mode 100644 index 000000000..2aaeefa80 --- /dev/null +++ b/packages/web/src/hooks/use-repos.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment jsdom + +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useRepos } from "./use-repos"; + +const mocks = vi.hoisted(() => ({ useSWR: vi.fn() })); + +vi.mock("swr", () => ({ default: mocks.useSWR })); +vi.mock("@/lib/auth-session", () => ({ + useAuthSession: () => ({ data: { user: {} }, status: "authenticated" }), +})); + +describe("useRepos", () => { + beforeEach(() => { + mocks.useSWR.mockReset(); + mocks.useSWR.mockReturnValue({ data: undefined, isLoading: false, error: undefined }); + }); + + it("does not request repositories when the caller is unauthorized", () => { + renderHook(() => useRepos(false)); + + expect(mocks.useSWR).toHaveBeenCalledWith(null); + }); + + it("requests repositories when enabled", () => { + renderHook(() => useRepos()); + + expect(mocks.useSWR).toHaveBeenCalledWith("/api/repos"); + }); +}); diff --git a/packages/web/src/hooks/use-repos.ts b/packages/web/src/hooks/use-repos.ts index f4f3799f6..56f3a3a7b 100644 --- a/packages/web/src/hooks/use-repos.ts +++ b/packages/web/src/hooks/use-repos.ts @@ -15,16 +15,21 @@ interface ReposResponse { repos: Repo[]; } -export function useRepos() { +/** + * Loads repositories for an authenticated user when enabled, allowing callers to suppress unauthorized requests. + */ +export function useRepos(enabled = true) { const { data: session, status } = useAuthSession(); - const { data, isLoading, error } = useSWR(session ? "/api/repos" : null); + const { data, isLoading, error } = useSWR( + enabled && session ? "/api/repos" : null + ); return { repos: data?.repos ?? [], // The fetch is gated on the auth session, so the list is still loading // while the session itself resolves — don't report an authoritative []. - loading: status === "loading" || isLoading, + loading: enabled && (status === "loading" || isLoading), error, }; } diff --git a/packages/web/src/hooks/use-workspace-administration.test.tsx b/packages/web/src/hooks/use-workspace-administration.test.tsx new file mode 100644 index 000000000..a07cb2641 --- /dev/null +++ b/packages/web/src/hooks/use-workspace-administration.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { SWRConfig } from "swr"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clearAuthSessionCache, useAuthSession } from "@/lib/auth-session"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { useWorkspaceAdministration } from "./use-workspace-administration"; + +vi.mock("@/lib/auth-session", () => ({ + useAuthSession: vi.fn(), + clearAuthSessionCache: vi.fn(), +})); +vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() })); + +const wrapper = ({ children }: { children: ReactNode }) => ( + new Map(), dedupingInterval: 0 }}>{children} +); + +const member = { + userId: "11111111111111111111111111111111", + displayName: "Ada", + email: "ada@example.com", + avatarUrl: null, + suspendedAt: null, + role: { id: "role_builtin_member", key: "member" as const, name: "Member" }, + createdAt: 1, +}; + +describe("useWorkspaceAdministration", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useAuthSession).mockReturnValue({ data: null, status: "unauthenticated" }); + vi.mocked(browserApiFetch).mockResolvedValue(new Response(null, { status: 204 })); + }); + + it("sends the simplified role and suspension mutation contracts", async () => { + const { result } = renderHook( + () => useWorkspaceAdministration({ readMembers: false, readRoles: false }), + { wrapper } + ); + + await act(() => result.current.updateMember(member, { kind: "role", roleId: "role_release" })); + await act(() => result.current.updateMember(member, { kind: "status", suspended: true })); + + expect(browserApiFetch).toHaveBeenNthCalledWith( + 1, + `/api/members/${member.userId}/role`, + expect.objectContaining({ method: "PUT", body: JSON.stringify({ roleId: "role_release" }) }) + ); + expect(browserApiFetch).toHaveBeenNthCalledWith( + 2, + `/api/members/${member.userId}/status`, + expect.objectContaining({ method: "PUT", body: JSON.stringify({ suspended: true }) }) + ); + }); + + it("treats self-suspension as successful and clears the authenticated session cache", async () => { + vi.mocked(useAuthSession).mockReturnValue({ + data: { user: { id: member.userId, name: "Ada", email: "ada@example.com", image: null } }, + status: "authenticated", + }); + vi.mocked(clearAuthSessionCache).mockResolvedValue(undefined); + const { result } = renderHook( + () => useWorkspaceAdministration({ readMembers: false, readRoles: false }), + { wrapper } + ); + + await expect( + act(() => result.current.updateMember(member, { kind: "status", suspended: true })) + ).resolves.toBeUndefined(); + + expect(clearAuthSessionCache).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web/src/hooks/use-workspace-administration.ts b/packages/web/src/hooks/use-workspace-administration.ts new file mode 100644 index 000000000..e193bbc0a --- /dev/null +++ b/packages/web/src/hooks/use-workspace-administration.ts @@ -0,0 +1,123 @@ +"use client"; + +import useSWR, { useSWRConfig } from "swr"; +import { + roleListResponseSchema, + workspaceMemberListResponseSchema, + type EffectiveAuthorization, + type RoleSummary, + type WorkspaceMember, +} from "@open-inspect/shared/rbac"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { clearAuthSessionCache, useAuthSession } from "@/lib/auth-session"; +import { currentUserAuthorizationKey } from "./use-current-user-authorization"; + +async function fetchMembers(): Promise { + const response = await browserApiFetch("/api/members"); + if (!response.ok) throw new Error(`Members request failed (${response.status})`); + return workspaceMemberListResponseSchema.parse(await response.json()); +} + +async function fetchRoles(): Promise { + const response = await browserApiFetch("/api/roles"); + if (!response.ok) throw new Error(`Roles request failed (${response.status})`); + return roleListResponseSchema.parse(await response.json()); +} + +/** + * Provides the workspace members and roles the current user may read, plus authorized member updates. + */ +export function useWorkspaceAdministration(input: { readMembers: boolean; readRoles: boolean }) { + const { mutate } = useSWRConfig(); + const { data: session } = useAuthSession(); + const members = useSWR(input.readMembers ? "/api/members" : null, fetchMembers); + const roles = useSWR(input.readRoles ? "/api/roles" : null, fetchRoles); + + async function updateMember( + user: WorkspaceMember, + action: { kind: "role"; roleId: string } | { kind: "status"; suspended: boolean } + ): Promise { + const path = + action.kind === "role" + ? (`/api/members/${encodeURIComponent(user.userId)}/role` as const) + : (`/api/members/${encodeURIComponent(user.userId)}/status` as const); + const body = + action.kind === "role" ? { roleId: action.roleId } : { suspended: action.suspended }; + const response = await browserApiFetch(path, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new Error(`Member update failed (${response.status})`); + + const nextRole = + action.kind === "role" ? roles.data?.find(({ id }) => id === action.roleId) : null; + try { + await members.mutate( + (current) => + current?.map((member) => { + if (member.userId !== user.userId) return member; + if (action.kind === "status") { + return { ...member, suspendedAt: action.suspended ? Date.now() : null }; + } + return nextRole ? { ...member, role: roleReference(nextRole) } : member; + }), + { revalidate: false } + ); + + if (action.kind === "role" && nextRole && nextRole.id !== user.role.id) { + await roles.mutate( + (current) => + current?.map((role) => ({ + ...role, + assignmentCount: + role.id === user.role.id + ? Math.max(0, role.assignmentCount - 1) + : role.id === nextRole.id + ? role.assignmentCount + 1 + : role.assignmentCount, + })), + { revalidate: false } + ); + } + + if (session?.user?.id === user.userId) { + const authorizationKey = currentUserAuthorizationKey(user.userId); + if (action.kind === "status" && action.suspended) { + await Promise.all([ + clearAuthSessionCache(), + mutate(authorizationKey, undefined, { revalidate: false }), + ]); + } else if (action.kind === "role" && nextRole) { + await mutate( + authorizationKey, + (current) => + current + ? { + ...current, + role: roleReference(nextRole), + permissions: nextRole.permissions, + } + : current, + { revalidate: false } + ); + } + } + } catch { + // The server mutation already committed. Cache maintenance must never + // turn that success into a retryable-looking mutation failure. + } + } + + return { + members: members.data ?? [], + roles: roles.data ?? [], + loading: (input.readMembers && members.isLoading) || (input.readRoles && roles.isLoading), + error: members.error ?? roles.error, + updateMember, + }; +} + +function roleReference(role: RoleSummary): WorkspaceMember["role"] { + return { id: role.id, key: role.key, name: role.name }; +} diff --git a/packages/web/src/lib/auth-session.tsx b/packages/web/src/lib/auth-session.tsx index 75fc51962..8a774dca9 100644 --- a/packages/web/src/lib/auth-session.tsx +++ b/packages/web/src/lib/auth-session.tsx @@ -58,6 +58,11 @@ export async function signOut(): Promise { if (!response.ok) { throw new Error(`Sign-out failed with status ${response.status}`); } + await clearAuthSessionCache(); +} + +/** Immediately reflect a server-side session revocation in the client cache. */ +export async function clearAuthSessionCache(): Promise { await mutate(BROWSER_AUTH_SESSION_PATH, null, false); }