diff --git a/README.md b/README.md index 304c79a66..58c915e48 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ The system uses a shared GitHub App installation for git operations (clone, fetc control plane mints short-lived installation tokens server-side and brokers them to sandboxes through the git credential helper on demand. This means: -- **All users share the same GitHub App credentials** - The GitHub App must be installed on your - organization's repositories, and any user of the system can access any repo the App has access to +- **Authorized users share the same GitHub App credentials** - The GitHub App must be installed on + your organization's repositories, and active users whose role permits repository use can access + any repo the App has access to - **No per-user repository access validation** - The system does not verify that a user has permission to access a specific repository before creating a session - **GitHub users' OAuth tokens are used for PR creation** - For GitHub logins, PRs are created using @@ -70,6 +71,9 @@ built for internal use where all employees are trusted and have access to compan 4. **Use GitHub's repository selection** - When installing the App, select specific repositories rather than "All repositories" +See [Authentication and Authorization](docs/AUTH.md) for workspace roles, session access, automation +ownership, bots, and member suspension. + ## Architecture ``` diff --git a/docs/AUTH.md b/docs/AUTH.md new file mode 100644 index 000000000..e8d9f4fc8 --- /dev/null +++ b/docs/AUTH.md @@ -0,0 +1,206 @@ +# Authentication and Authorization + +Open-Inspect uses authentication to establish who you are and workspace authorization to decide what +you can do. This guide explains the behavior users and workspace administrators will see. + +> **Important:** Open-Inspect is designed for a single trusted organization. A deployment is one +> workspace, and the source-control App installation defines the repositories available to that +> workspace. Roles control which Open-Inspect features a person can use; they are not per-repository +> access lists. + +--- + +## Signing In + +A deployment can offer GitHub sign-in, Google sign-in, or both. The sign-in page shows only the +providers configured by the deployment operator. + +Signing in has two stages: + +1. Your identity provider verifies your identity and email address. +2. The deployment's admission rules determine whether you may join the workspace. + +Depending on the deployment configuration, admission can be limited by: + +- GitHub username +- Verified email address +- Verified email domain +- Active membership in an allowed GitHub organization + +These rules are checked when you sign in. Removing someone from an allowlist or GitHub organization +does not end an existing browser session; an Administrator or Owner can suspend the member when +access must be revoked immediately. + +Authentication does not make someone an Owner or Administrator. Every admitted user has exactly one +workspace role, and new users receive the Member role by default. + +## Workspace Roles + +Open-Inspect includes four built-in roles. + +| Capability | Owner | Administrator | Member | Viewer | +| ------------------------------------------------- | :---: | :-----------: | :----: | :----: | +| View repositories and environments | Yes | Yes | Yes | Yes | +| Use repositories and environments in sessions | Yes | Yes | Yes | No | +| Manage shared settings, integrations, and secrets | Yes | Yes | No | No | +| Create sessions | Yes | Yes | Yes | No | +| View every session | Yes | Yes | Yes | Yes | +| Collaborate in and manage sessions | Yes | Yes | Yes | No | +| View automations | Yes | Yes | Yes | Yes | +| Create automations | Yes | Yes | Yes | No | +| Manage and trigger own automations | Yes | Yes | Yes | No | +| Manage and trigger any automation | Yes | Yes | No | No | +| View and manage workspace members | Yes | Yes | No | No | +| Transfer workspace ownership | Yes | No | No | No | +| View analytics | Yes | Yes | Yes | Yes | +| View provider accounts | Yes | Yes | Yes | No | +| View image-build history | Yes | Yes | Yes | Yes | +| Manage personal skill profiles | Yes | Yes | Yes | No | + +### Owner + +Owners have full access to the workspace. Only Owners can grant or remove the Owner role or suspend +and restore another Owner. Open-Inspect also prevents the final active Owner from being suspended or +demoted, so the workspace cannot accidentally lose all ownership. + +### Administrator + +Administrators can operate the workspace day to day. They can manage members, sessions, automations, +repositories, environments, provider accounts, integrations, and secrets. They cannot transfer +ownership, change who holds the Owner role, or suspend and restore an Owner. + +### Member + +Members can create and use sessions, collaborate in existing sessions, use shared repositories and +environments, and create automations. They can manage and manually trigger automations they own but +cannot modify another person's automation or administer shared configuration. They can view +workspace analytics. + +### Viewer + +Viewers have read-only access to shared workspace resources. They can inspect sessions, automations, +analytics, repositories, environments, skills, and MCP servers. They cannot create or prompt +sessions, access sandboxes, manage personal skill profiles, trigger automations, or change shared +configuration. + +## How Session Access Works + +Sessions are workspace resources rather than private resources owned by their creator. + +- Anyone with session read access can view every session in the workspace. +- Anyone with collaboration access can prompt and contribute to every session. +- Anyone with lifecycle access can stop, retry, archive, unarchive, and otherwise manage every + session. +- Anyone with sandbox access can use supported sandbox tools for every session. +- Anyone with delete access can delete every session. + +The creator shown on a session records attribution; it is not an access list. Likewise, participant +labels identify who contributed to a session but do not grant or remove workspace permissions. The +**Mine** filter is a convenience for finding sessions you created, not a security boundary. + +Creating a session also requires permission to use its selected repository or environment. A role +may therefore be able to view an existing session without being allowed to create a new one. + +New HTTP requests reflect role changes and suspension immediately. Live browser connections to a +session are rechecked at least every five minutes, so a connection may remain open for up to five +minutes after access changes. Recreating the session is not required. + +## How Automation Access Works + +Automation definitions and run history are visible workspace-wide to roles with automation read +access. Creating, changing, and manually triggering automations use ownership rules. + +- Members can manage and manually trigger automations they own. +- Administrators and Owners can manage and manually trigger any automation. +- Viewers can inspect automations but cannot create, change, or run them. + +Automation ownership follows the signed-in account that created it, not a display name or external +provider username. + +### Scheduled and Event Runs + +Scheduled and event-driven runs execute under the automation owner's authority. At run time, the +owner must still be active and allowed to create sessions and use every selected repository or +environment. If those permissions have been removed, the run does not start. + +### Manual Runs + +A manual run executes under the authority of the person who clicked **Run**, even when an +Administrator or Owner triggers someone else's automation. The requester must be allowed both to +trigger that automation and to create the resulting session with its selected resources. Their +identity and linked source-control credentials are used for that run. + +See [Automations](AUTOMATIONS.md) for trigger setup and run behavior. + +## Bots and Integrations + +Slack, GitHub, and Linear integrations act on behalf of a workspace user when they handle a user +request. Their effective access is limited by both: + +- The acting user's current role +- The integration's fixed set of allowed operations + +This means an integration cannot bypass a suspended user or perform workspace administration simply +because the acting user is an Owner. Calls that do not identify an acting user are denied unless a +specific integration route explicitly permits that operation. + +Some integrations also apply their own ingress rules. For example, the GitHub integration may +require an allowed trigger user or sufficient repository collaborator access before it sends a +request to Open-Inspect. + +## Suspension + +Suspending a member disables their workspace access without deleting their account or historical +attribution. + +After suspension: + +- New browser and bot operations are denied. +- Existing browser sign-in sessions are invalidated. +- Live browser session connections close within five minutes. +- Scheduled and event-driven automations owned by the member no longer pass run authorization. +- Existing session history and authorship remain intact. + +Suspension does not automatically stop a sandbox that is already executing. An Administrator or +Owner can manage that session separately. + +## Repository and Credential Boundaries + +Open-Inspect uses a shared source-control App installation for clone, fetch, and push operations. +The App should be installed only on repositories intended for the workspace. + +A user's role determines whether they may read or use workspace repositories, but Open-Inspect does +not compare that role with the user's personal GitHub access for each repository. Linked GitHub +credentials can be used for actions such as attributed pull-request creation; when no suitable user +credential is available, supported operations may use the shared App identity. + +Secrets and provider credentials are not made visible through role-based read access. Administrative +permissions control who can configure them, and saved secret values are not returned to the browser. +See [Secrets Management](SECRETS.md) for details. + +## Workspace Administration + +Owners and Administrators can manage members from **Settings > Workspace access**. Depending on +their own role, they can: + +- Review workspace members and assigned roles +- Change a member's role +- Suspend or restore a member + +Only an Owner can assign or remove the Owner role or suspend and restore another Owner. The final +active Owner cannot be suspended or demoted. + +### Initial Owner Setup + +The first person who signs in receives the default Member role and is not promoted to Owner +automatically. On a new deployment, the intended Owner must sign in once, after which a deployment +operator runs the Owner bootstrap command using that person's Open-Inspect user ID. See +[Getting Started](GETTING_STARTED.md#step-7a-bootstrap-the-workspace-owner) for the deployment +steps. + +## Related Guides + +- [Getting Started](GETTING_STARTED.md) +- [Automations](AUTOMATIONS.md) +- [Secrets Management](SECRETS.md) +- [How Open-Inspect Works](HOW_IT_WORKS.md) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index d9c2402dc..9600c52d6 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -302,10 +302,11 @@ GitHub OAuth sign-in, but its client pair is optional when Google is the only si > **Keep "User-to-server token expiration" active** (GitHub App → **Optional Features**; it is > the default for newly created Apps, but activate it if yours predates that default). Expiring > user tokens are what make GitHub return a **refresh token** at sign-in, and Open-Inspect stores - > that per-user credential so sessions clone, commit, and push **as the signed-in user**. With - > expiration deactivated — or on an **OAuth App**, which never issues a refresh token — no - > per-user credential is captured and sessions fall back to the shared GitHub App **bot** - > identity for repository access. + > that per-user credential for attributed GitHub operations such as pull-request creation. Clone, + > fetch, and push authentication still use the shared GitHub App installation. With expiration + > deactivated — or on an **OAuth App**, which never issues a refresh token — no per-user + > credential is captured, so supported attributed operations fall back to the shared GitHub App + > **bot** identity. 5. Set **Repository permissions**: - Actions: **Read-only** _(required for GitHub workflow-run automations)_ @@ -651,10 +652,9 @@ configurations because they authorize repository operations; they do not enable ### Enable Google Login (Optional) -Google login lets non-developer users (PMs, support agents) sign in without a GitHub account. They -get the same flat access as everyone else; git operations still use the shared GitHub App, and their -PRs fall back to the App bot (no personal GitHub attribution unless the same verified email is also -a linked GitHub identity). +Google login lets non-developer users (PMs, support agents) sign in without a GitHub account. Git +operations still use the shared GitHub App, and their PRs fall back to the App bot (no personal +GitHub attribution unless the same verified email is also a linked GitHub identity). 1. In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), create an **OAuth client ID** of type **Web application**. @@ -726,6 +726,52 @@ Terraform will update the workers with the required bindings. --- +## Step 7a: Bootstrap the Workspace Owner + +Owner assignment is an explicit operator action. After both deployment phases complete: + +1. Have the intended Owner sign in to the deployed web application once. This creates their + canonical user and default role assignment. +2. While signed in, open `/api/auth/get-session` on the web application origin and record the + 32-character lowercase hexadecimal `user.id`. The bootstrap command accepts this canonical ID, + never an email address. +3. Obtain the D1 database name with `terraform output -raw d1_database_name` from + `terraform/environments/production`. +4. From the repository root, run the remote dry run (the default): + +```bash +npm run rbac:bootstrap-owner -- \ + --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \ + --user "" +``` + +5. Confirm the preflight result is `ready` (or `no-op` when the target is already the current + unsuspended Owner), then execute the same command with `--execute`: + +```bash +npm run rbac:bootstrap-owner -- \ + --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \ + --user "" \ + --execute +``` + +The command uses Wrangler credentials (`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, or +`wrangler login`) and targets remote D1. It refuses a suspended/missing user, a missing or ambiguous +assignment, or another unsuspended Owner. There is no force option. Execution is one atomic Wrangler +SQL file: it writes one redacted `workspace.owner_bootstrapped` service audit event and replaces the +target's assignment. A no-op writes nothing. + +6. Verify the control-plane health response contains `"rbac":{"ownerAssignment":"present"}`: + +```bash +curl "$(terraform -chdir=terraform/environments/production output -raw control_plane_url)/health" +``` + +This health value reports current state: `present` means at least one Owner assignment belongs to an +unsuspended user. + +--- + ## Step 7b: Complete Slack Setup (If Using Slack) Now that the Slack bot worker is deployed, configure the agent experience, App Home, and event diff --git a/packages/control-plane/src/router.policy.test.ts b/packages/control-plane/src/router.policy.test.ts index 2a0ad18f2..d5e1b4d91 100644 --- a/packages/control-plane/src/router.policy.test.ts +++ b/packages/control-plane/src/router.policy.test.ts @@ -154,11 +154,7 @@ describe("route policy table", () => { }); expect(routeFor("POST", "/sessions/session-1/ws-token")?.authorization).toMatchObject({ kind: "active-user", - allOf: [ - { kind: "permission", permission: "sessions.read" }, - { kind: "permission", permission: "sessions.collaborate" }, - { kind: "permission", permission: "sessions.lifecycle" }, - ], + allOf: [{ kind: "permission", permission: "sessions.read" }], }); expect(routeFor("POST", "/sessions/session-1/stop")?.authorization).toMatchObject({ service: { kind: "actor", actorlessGrants: [{ service: "linear-bot" }] }, diff --git a/packages/control-plane/src/router.ts b/packages/control-plane/src/router.ts index e13e0a4ce..e06a57687 100644 --- a/packages/control-plane/src/router.ts +++ b/packages/control-plane/src/router.ts @@ -19,7 +19,11 @@ import { UserStore } from "./db/user-store"; import { AutomationStore } from "./db/automation-store"; import { AuthorizationError, AuthorizationService } from "./authorization/service"; import { serviceAllowsPermission } from "./authorization/service-permissions"; -import { SCOPED_PERMISSION_PAIRS, resolveScopedPermission } from "@open-inspect/shared/rbac"; +import { + SCOPED_PERMISSION_PAIRS, + hasScopedPermission, + resolveScopedPermission, +} from "@open-inspect/shared/rbac"; import { createLogger } from "./logger"; import type { BackgroundTasks } from "./platform-ports"; import { @@ -478,11 +482,13 @@ async function enforceAutomationRequirement( const automation = await store.resolveCanonicalOwner(storedAutomation); const permissionStem = `automations.${requirement.operation}` as const; - const permissionScope = resolveScopedPermission(permissionStem, authorization.permissions); const ownPermission = SCOPED_PERMISSION_PAIRS[permissionStem].own; if ( - !permissionScope || - (permissionScope === "own" && automation.user_id !== ctx.principal.userId) + !hasScopedPermission( + permissionStem, + authorization.permissions, + automation.user_id === ctx.principal.userId + ) ) { return json( { error: "Forbidden", code: "permission_required", permission: ownPermission }, diff --git a/packages/control-plane/src/routes/session-runtime-proxy.test.ts b/packages/control-plane/src/routes/session-runtime-proxy.test.ts index 62688ce26..9ecb62ae2 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.test.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it, vi } from "vitest"; import { SessionInternalPaths } from "../session/contracts"; +import type { PermissionId } from "@open-inspect/shared/rbac"; import type { RequestContext } from "./shared"; import type { SqlDatabase } from "../db/sql-database"; import { sessionRuntimeProxyRoutes } from "./session-runtime-proxy"; import type { Env } from "../types"; import { TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support"; -function createCtx(db: SqlDatabase = {} as SqlDatabase): RequestContext { +function createCtx( + db: SqlDatabase = {} as SqlDatabase, + permissions: PermissionId[] = ["sessions.read"] +): RequestContext { return { trace_id: "trace-1", request_id: "req-1", @@ -16,6 +20,12 @@ function createCtx(db: SqlDatabase = {} as SqlDatabase): RequestContext { kind: "user", userId: "user-1", }, + authorization: { + userId: "user-1", + suspendedAt: null, + role: { id: "role-1", key: "viewer", name: "Viewer" }, + permissions, + }, metrics: { d1Queries: [], spans: {}, @@ -44,15 +54,13 @@ function getHandler(method: string, path: string) { } describe("session runtime proxy routes", () => { - it.each([ - ["snapshot", "/sessions/session-1", SessionInternalPaths.snapshot], - ["sandbox access", "/sessions/session-1/sandbox-access", SessionInternalPaths.sandboxAccess], - ])("forwards %s for users", async (_name, path, internalPath) => { + it("forwards sandbox access for users", async () => { const requests: Request[] = []; const fetch = vi.fn(async (request: Request) => { requests.push(request); return Response.json({ sessionId: "session-1" }); }); + const path = "/sessions/session-1/sandbox-access"; const { handler, match } = getHandler("GET", path); const response = await handler( @@ -63,10 +71,64 @@ describe("session runtime proxy routes", () => { ); expect(response.status).toBe(200); - expect(new URL(requests[0].url).pathname).toBe(internalPath); + expect(new URL(requests[0].url).pathname).toBe(SessionInternalPaths.sandboxAccess); expect(fetch).toHaveBeenCalledOnce(); }); + it.each([ + { permissions: ["sessions.read"] as PermissionId[], exposed: false }, + { + permissions: ["sessions.read", "sessions.sandbox_access"] as PermissionId[], + exposed: true, + }, + ])("scopes snapshot sandbox locations to sandbox access ($exposed)", async (input) => { + const fetch = vi.fn(async () => + Response.json({ + session: { + id: "session-1", + title: "Session", + repoOwner: "acme", + repoName: "web", + baseBranch: "main", + branchName: "feature", + status: "active", + sandboxStatus: "ready", + messageCount: 0, + createdAt: 1, + codeServerUrl: "https://code.example", + vncUrl: "https://vnc.example", + ttydUrl: "https://terminal.example", + tunnelUrls: { "3000": "https://app.example" }, + sandboxDashboardUrl: "https://provider.example", + }, + artifacts: [], + promptQueue: [], + timeline: { events: [], hasMore: false, cursor: null }, + }) + ); + const path = "/sessions/session-1"; + const { handler, match } = getHandler("GET", path); + + const response = await handler( + new Request(`https://test.local${path}`), + createEnv(fetch), + match, + createCtx({} as SqlDatabase, input.permissions) + ); + const snapshot = (await response.json()) as { session: Record }; + + expect(response.status).toBe(200); + if (input.exposed) { + expect(snapshot.session).toHaveProperty("codeServerUrl", "https://code.example"); + } else { + expect(snapshot.session).not.toHaveProperty("codeServerUrl"); + expect(snapshot.session).not.toHaveProperty("vncUrl"); + expect(snapshot.session).not.toHaveProperty("ttydUrl"); + expect(snapshot.session).not.toHaveProperty("tunnelUrls"); + expect(snapshot.session).not.toHaveProperty("sandboxDashboardUrl"); + } + }); + it("forwards event query strings through the session runtime dependency", async () => { const requests: Request[] = []; const fetch = vi.fn(async (request: Request) => { diff --git a/packages/control-plane/src/routes/session-runtime-proxy.ts b/packages/control-plane/src/routes/session-runtime-proxy.ts index c6838a29a..7eb9244a7 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.ts @@ -3,6 +3,10 @@ import type { SessionParticipantProfilesResponse, SessionParticipantProfile, } from "@open-inspect/shared/types/sessions"; +import { + redactSessionSnapshotSandboxAccess, + sessionSnapshotSchema, +} from "@open-inspect/shared/types/server-messages"; import { z } from "zod"; import { UserStore } from "../db/user-store"; import { SessionIndexStore } from "../db/session-index"; @@ -181,6 +185,29 @@ async function handleParticipantProfiles( return Response.json({ profiles } satisfies SessionParticipantProfilesResponse); } +async function handleSessionSnapshot( + _request: Request, + _env: Env, + match: RegExpMatchArray, + ctx: SessionRouteContext +): Promise { + const sessionId = getSessionId(match); + if (sessionId instanceof Response) return sessionId; + + const response = await ctx.sessionRuntime.fetch(sessionId, SessionInternalPaths.snapshot); + if (response.status === 404) return error("Session not found", 404); + if (!response.ok) return response; + + const parsed = sessionSnapshotSchema.safeParse(await response.json().catch(() => null)); + if (!parsed.success) return error("Invalid session snapshot", 502); + const snapshot = ctx.authorization?.permissions.includes("sessions.sandbox_access") + ? parsed.data + : redactSessionSnapshotSandboxAccess(parsed.data); + const headers = new Headers(response.headers); + headers.delete("Content-Length"); + return Response.json(snapshot, { headers }); +} + async function handleCreatePR( request: Request, _env: Env, @@ -297,14 +324,15 @@ export const sessionRuntimeProxyRoutes: Route[] = [ internalPath: SessionInternalPaths.sandboxAccess, authorization: requirePermission("sessions.sandbox_access"), }), - simpleProxyRoute({ - policy: SCM_AGNOSTIC_HUMAN_USER_ROUTE, - method: "GET", - routePath: "/sessions/:id", - internalPath: SessionInternalPaths.snapshot, - authorization: requirePermission("sessions.read"), - notFoundMessage: "Session not found", - }), + defineRoute( + SCM_AGNOSTIC_HUMAN_USER_ROUTE, + sessionRoute({ + method: "GET", + pattern: parsePattern("/sessions/:id"), + authorization: requirePermission("sessions.read"), + handler: handleSessionSnapshot, + }) + ), simpleProxyRoute({ policy: GITHUB_USER_OR_SERVICE_ROUTE, method: "POST", diff --git a/packages/control-plane/src/routes/session-ws-token.test.ts b/packages/control-plane/src/routes/session-ws-token.test.ts index 608d5f471..ec925177d 100644 --- a/packages/control-plane/src/routes/session-ws-token.test.ts +++ b/packages/control-plane/src/routes/session-ws-token.test.ts @@ -37,7 +37,7 @@ function createContext(db: SqlDatabase = accessDatabase().db): RequestContext { userId: "user-1", suspendedAt: null, role: { id: "role-1", key: "member", name: "Member" }, - permissions: ["sessions.collaborate"], + permissions: ["sessions.read"], }, metrics: { d1Queries: [], diff --git a/packages/control-plane/src/routes/session-ws-token.ts b/packages/control-plane/src/routes/session-ws-token.ts index 096c2a775..8e959cde6 100644 --- a/packages/control-plane/src/routes/session-ws-token.ts +++ b/packages/control-plane/src/routes/session-ws-token.ts @@ -1,5 +1,5 @@ import { applyIdentityEnforcement } from "../auth/identity-enforcement"; -import { SESSION_WEBSOCKET_PERMISSIONS } from "@open-inspect/shared/rbac"; +import { SESSION_WEBSOCKET_CONNECT_PERMISSION } from "@open-inspect/shared/rbac"; import { SessionInternalPaths, sessionScmDisplayFieldsSchema } from "../session/contracts"; import type { Env } from "../types"; import { @@ -8,8 +8,7 @@ import { GITHUB_USER_OR_SERVICE_ROUTE, parseJsonBody, parsePattern, - permissionRequirement, - requireAll, + requirePermission, type Route, } from "./shared"; import { sessionRoute, type SessionRouteContext } from "./session-route"; @@ -60,7 +59,7 @@ export const sessionWsTokenRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE sessionRoute({ method: "POST", pattern: parsePattern("/sessions/:id/ws-token"), - authorization: requireAll(...SESSION_WEBSOCKET_PERMISSIONS.map(permissionRequirement)), + authorization: requirePermission(SESSION_WEBSOCKET_CONNECT_PERMISSION), handler: handleSessionWsToken, }), ]); diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts index e4cb2453f..b5b2c5752 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts @@ -477,16 +477,14 @@ async function expectEarlyBridgeStartup(kind: ProviderStartupKind): Promise (message as { type: string }).type === "sandbox_access_changed" ) - ).toHaveLength(1); - expect(accessAtBroadcast).toEqual([ - { - code_server_url: access.codeServerUrl, - code_server_password: access.codeServerPassword, - vnc_url: access.vncAccess.url, - vnc_password: access.vncAccess.password, - tunnel_urls: JSON.stringify(access.tunnelUrls), - }, - ]); + ).not.toHaveLength(0); + expect(accessAtBroadcast.at(-1)).toEqual({ + code_server_url: access.codeServerUrl, + code_server_password: access.codeServerPassword, + vnc_url: access.vncAccess.url, + vnc_password: access.vncAccess.password, + tunnel_urls: JSON.stringify(access.tunnelUrls), + }); } // ==================== Tests ==================== @@ -844,13 +842,10 @@ describe("SandboxLifecycleManager", () => { expect(storage.calls).toContain("updateSandboxModalObjectId:provider-obj-123"); expect( - broadcaster.messages.filter((m) => (m as { type: string }).type === "sandbox_dashboard_url") - ).toEqual([ - { - type: "sandbox_dashboard_url", - url: "https://provider.example/provider-obj-123", - }, - ]); + broadcaster.messages.filter( + (m) => (m as { type: string }).type === "sandbox_access_changed" + ) + ).toContainEqual({ type: "sandbox_access_changed" }); }); it("does not broadcast sandbox_dashboard_url when no builder is configured", async () => { @@ -873,7 +868,7 @@ describe("SandboxLifecycleManager", () => { expect(storage.calls).toContain("updateSandboxModalObjectId:provider-obj-123"); expect( - broadcaster.messages.some((m) => (m as { type: string }).type === "sandbox_dashboard_url") + broadcaster.messages.some((m) => (m as { type: string }).type === "sandbox_access_changed") ).toBe(false); }); @@ -1360,13 +1355,10 @@ describe("SandboxLifecycleManager", () => { expect(storage.calls).toContain("updateSandboxModalObjectId:restored-obj-456"); expect( - broadcaster.messages.filter((m) => (m as { type: string }).type === "sandbox_dashboard_url") - ).toEqual([ - { - type: "sandbox_dashboard_url", - url: "https://provider.example/restored-obj-456", - }, - ]); + broadcaster.messages.filter( + (m) => (m as { type: string }).type === "sandbox_access_changed" + ) + ).toContainEqual({ type: "sandbox_access_changed" }); }); it("broadcasts sandbox_dashboard_url after resume when provider object id changes", async () => { @@ -1405,13 +1397,10 @@ describe("SandboxLifecycleManager", () => { expect(provider.resumeSandbox).toHaveBeenCalled(); expect(storage.calls).toContain("updateSandboxModalObjectId:new-provider-obj"); expect( - broadcaster.messages.filter((m) => (m as { type: string }).type === "sandbox_dashboard_url") - ).toEqual([ - { - type: "sandbox_dashboard_url", - url: "https://provider.example/new-provider-obj", - }, - ]); + broadcaster.messages.filter( + (m) => (m as { type: string }).type === "sandbox_access_changed" + ) + ).toContainEqual({ type: "sandbox_access_changed" }); }); it("broadcasts sandbox_dashboard_url after resume when provider object id is unchanged", async () => { @@ -1450,13 +1439,10 @@ describe("SandboxLifecycleManager", () => { expect(provider.resumeSandbox).toHaveBeenCalled(); expect(storage.calls).not.toContain("updateSandboxModalObjectId:same-provider-obj"); expect( - broadcaster.messages.filter((m) => (m as { type: string }).type === "sandbox_dashboard_url") - ).toEqual([ - { - type: "sandbox_dashboard_url", - url: "https://provider.example/same-provider-obj", - }, - ]); + broadcaster.messages.filter( + (m) => (m as { type: string }).type === "sandbox_access_changed" + ) + ).toContainEqual({ type: "sandbox_access_changed" }); }); it("does not carry a predecessor's runtime version onto a replacement's snapshot", async () => { @@ -3540,11 +3526,7 @@ describe("SandboxLifecycleManager", () => { expect(storage.calls).toContain("updateSandboxTunnelUrls"); expect( - broadcaster.messages.some( - (m) => - (m as { type: string }).type === "tunnel_urls" && - (m as { urls: Record }).urls["3000"] === "https://tunnel.example.com" - ) + broadcaster.messages.some((m) => (m as { type: string }).type === "sandbox_access_changed") ).toBe(true); }); @@ -3644,11 +3626,7 @@ describe("SandboxLifecycleManager", () => { expect(storage.calls).toContain("updateSandboxTunnelUrls"); expect( - broadcaster.messages.some( - (m) => - (m as { type: string }).type === "tunnel_urls" && - (m as { urls: Record }).urls["3000"] === "https://tunnel.example.com" - ) + broadcaster.messages.some((m) => (m as { type: string }).type === "sandbox_access_changed") ).toBe(true); }); }); diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.ts b/packages/control-plane/src/sandbox/lifecycle/manager.ts index b549de9e2..a2aabdce6 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.ts @@ -1597,7 +1597,7 @@ export class SandboxLifecycleManager implements SandboxLifecycle { this.log.debug("Broadcasting sandbox dashboard URL", { provider_object_id: providerObjectId, }); - this.broadcaster.broadcast({ type: "sandbox_dashboard_url", url }); + this.broadcaster.broadcast({ type: "sandbox_access_changed" }); } } @@ -1640,7 +1640,7 @@ export class SandboxLifecycleManager implements SandboxLifecycle { if (!urls || Object.keys(urls).length === 0) return; this.log.info("Storing and broadcasting tunnel URLs", { ports: Object.keys(urls) }); await this.storage.updateSandboxTunnelUrls(urls); - this.broadcaster.broadcast({ type: "tunnel_urls", urls }); + this.broadcaster.broadcast({ type: "sandbox_access_changed" }); } /** Mint and persist terminal access. */ diff --git a/packages/control-plane/src/session/client-command-facade.ts b/packages/control-plane/src/session/client-command-facade.ts index f7b7f4d99..f03aa879b 100644 --- a/packages/control-plane/src/session/client-command-facade.ts +++ b/packages/control-plane/src/session/client-command-facade.ts @@ -20,6 +20,7 @@ import type { SessionEventStream, SessionHistoryPage } from "./event-stream"; import type { SessionConnectionAuthenticator } from "./connection-authenticator"; import type { SessionMessageQueue } from "./message-queue"; import type { PresenceService } from "./presence-service"; +import type { PermissionId } from "@open-inspect/shared/rbac"; export class SessionClientCommandFacade implements SessionClientCommands { constructor( @@ -59,4 +60,11 @@ export class SessionClientCommandFacade implements SessionClientCommands { + return this.authenticator.authorizeClientCommand(client.userId, permission); + } } diff --git a/packages/control-plane/src/session/components.ts b/packages/control-plane/src/session/components.ts index f1f80b69e..85858afde 100644 --- a/packages/control-plane/src/session/components.ts +++ b/packages/control-plane/src/session/components.ts @@ -22,7 +22,6 @@ */ import { resolveAppName } from "@open-inspect/shared/app-name"; -import { SESSION_WEBSOCKET_PERMISSIONS } from "@open-inspect/shared/rbac"; import { DEFAULT_MODEL } from "@open-inspect/shared/models"; import { generateId, hashToken, encryptToken } from "../auth/crypto"; import { resolveSandboxBackendName } from "../sandbox/provider-name"; @@ -671,6 +670,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi sessionCoreRepository, sandboxRepository, repoSecretsEncryptionKey, + sandboxDashboardSettings, log, }); @@ -687,23 +687,20 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi snapshotReader, schedulePullRequestRefresh, scmProviderName, - verifyAuthorization: async (userId) => { - if (!db) return "unavailable"; + resolveAuthorization: async (userId) => { + if (!db) return { kind: "unavailable" }; try { const authorization = await new AuthorizationService(db).getEffectiveAuthorization(userId); - return authorization.suspendedAt === null && - SESSION_WEBSOCKET_PERMISSIONS.every((permission) => - authorization.permissions.includes(permission) - ) - ? "valid" - : "rejected"; + return authorization.suspendedAt === null + ? { kind: "valid", authorization } + : { kind: "rejected" }; } catch (error) { - if (error instanceof AuthorizationError) return "rejected"; + if (error instanceof AuthorizationError) return { kind: "rejected" }; log.error("WebSocket authorization verification failed", { user_id: userId, error: error instanceof Error ? error : String(error), }); - return "unavailable"; + return { kind: "unavailable" }; } }, log, diff --git a/packages/control-plane/src/session/connection-authenticator.ts b/packages/control-plane/src/session/connection-authenticator.ts index 016c1c29c..80640ee0b 100644 --- a/packages/control-plane/src/session/connection-authenticator.ts +++ b/packages/control-plane/src/session/connection-authenticator.ts @@ -1,5 +1,9 @@ import { isSessionPromptable } from "@open-inspect/shared/types/session-activity"; -import type { ServerMessage } from "@open-inspect/shared/types/server-messages"; +import type { EffectiveAuthorization, PermissionId } from "@open-inspect/shared/rbac"; +import { + redactSessionSnapshotSandboxAccess, + type ServerMessage, +} from "@open-inspect/shared/types/server-messages"; import { WS_AUTHORIZATION_REVOKED_REASON, WS_CLOSE_AUTHORIZATION_REVOKED, @@ -45,12 +49,18 @@ export interface SessionConnectionAuthenticatorDeps { snapshotReader: SessionSnapshotReader; schedulePullRequestRefresh: (trigger: "open" | "manual") => void; scmProviderName: SourceControlProviderName; - /** Revalidate a user's session-collaboration permission before granting a lease. */ - verifyAuthorization: (userId: string) => Promise<"valid" | "rejected" | "unavailable">; + /** Resolve a user's current authorization at the start of a subscription or command. */ + resolveAuthorization: (userId: string) => Promise; /** The session-scoped logger; upgrade/subscribe paths also receive request-scoped children. */ log: Logger; } +type AuthorizationResolution = + | { kind: "valid"; authorization: EffectiveAuthorization } + | { kind: "rejected" | "unavailable" }; + +export type ClientCommandAuthorization = "allowed" | "denied" | "unavailable"; + /** * Admits connections to the session: sandbox WebSocket upgrades (token + * lifecycle-state guards, re-checked after the non-storage token-hash await), @@ -270,18 +280,23 @@ export class SessionConnectionAuthenticator { // Authorization is intentionally sampled once at the start of this // subscription request. A concurrent role change takes effect when this // bounded lease expires, not midway through an in-flight request. - const authorization = await this.deps.verifyAuthorization(participant.canonical_user_id); - if (authorization !== "valid") { + const authorization = await this.deps.resolveAuthorization(participant.canonical_user_id); + if ( + authorization.kind !== "valid" || + !authorization.authorization.permissions.includes("sessions.read") + ) { log.warn("ws.connect", { event: "ws.connect", ws_type: "client", outcome: "auth_failed", reject_reason: - authorization === "unavailable" ? "authorization_unavailable" : "authorization_denied", + authorization.kind === "unavailable" + ? "authorization_unavailable" + : "authorization_denied", participant_id: participant.id, user_id: participant.canonical_user_id, }); - if (authorization === "unavailable") { + if (authorization.kind === "unavailable") { wsManager.close(ws, WS_CLOSE_INTERNAL_ERROR, "Authorization temporarily unavailable"); } else { wsManager.close(ws, WS_CLOSE_AUTHORIZATION_REVOKED, WS_AUTHORIZATION_REVOKED_REASON); @@ -322,7 +337,12 @@ export class SessionConnectionAuthenticator { try { const activated = await wsManager.activateClient(ws, clientInfo, () => - this.completeClientSubscription(ws, clientInfo, enrichment) + this.completeClientSubscription( + ws, + clientInfo, + enrichment, + authorization.authorization.permissions.includes("sessions.sandbox_access") + ) ); if (!activated) { wsManager.close(ws, 4009, "Session synchronization failed"); @@ -361,16 +381,20 @@ export class SessionConnectionAuthenticator { private completeClientSubscription( ws: WebSocket, client: ClientInfo, - enrichment: Parameters[0] + enrichment: Parameters[0], + canAccessSandbox: boolean ): boolean { const { wsManager, snapshotReader } = this.deps; const snapshot = snapshotReader.readSessionSnapshot(enrichment); if (!snapshot) return false; + const authorizedSnapshot = canAccessSandbox + ? snapshot + : redactSessionSnapshotSandboxAccess(snapshot); if ( !wsManager.send(ws, { type: "subscribed", - ...snapshot, + ...authorizedSnapshot, participantId: client.participantId, participant: { participantId: client.participantId, @@ -386,6 +410,18 @@ export class SessionConnectionAuthenticator { return true; } + /** Samples one permission before dispatching a WebSocket command. */ + async authorizeClientCommand( + userId: string, + permission: PermissionId + ): Promise { + const resolution = await this.deps.resolveAuthorization(userId); + if (resolution.kind === "unavailable") return "unavailable"; + if (resolution.kind === "rejected") return "denied"; + if (resolution.kind !== "valid") return "denied"; + return resolution.authorization.permissions.includes(permission) ? "allowed" : "denied"; + } + /** Return authorized client state, recovering an unexpired lease after hibernation. */ getClientInfo(ws: WebSocket): ClientInfo | null { const { wsManager, log } = this.deps; diff --git a/packages/control-plane/src/session/message-router.ts b/packages/control-plane/src/session/message-router.ts index 8cab30ee8..d3118a04b 100644 --- a/packages/control-plane/src/session/message-router.ts +++ b/packages/control-plane/src/session/message-router.ts @@ -1,6 +1,7 @@ import { sandboxEventSchema, type SandboxEvent } from "@open-inspect/shared/types/sandbox-events"; import { clientRequestIdSchema } from "@open-inspect/shared/types/prompts"; import { clientMessageSchema, type ClientMessage } from "@open-inspect/shared/types/websocket"; +import type { PermissionId } from "@open-inspect/shared/rbac"; import type { Logger } from "../logger"; import type { SessionHistoryPage } from "./event-stream"; import type { Clock, ConnectedClient, SocketRegistry } from "./ports"; @@ -33,6 +34,10 @@ export interface SessionClientCommands; limit?: number; }) => SessionHistoryPage; + authorize: ( + client: Client, + permission: PermissionId + ) => Promise<"allowed" | "denied" | "unavailable">; } export interface SessionMessageRouterDeps { @@ -104,15 +109,19 @@ export class SessionMessageRouter { switch (data.type) { case "prompt": + if (!(await this.authorizeCommand(connection, client, "sessions.collaborate"))) break; await this.deps.clientCommands.submitPrompt(connection, client, data); break; case "cancel_prompt": + if (!(await this.authorizeCommand(connection, client, "sessions.lifecycle"))) break; await this.deps.clientCommands.cancelPrompt(connection, data); break; case "stop": + if (!(await this.authorizeCommand(connection, client, "sessions.lifecycle"))) break; await this.deps.clientCommands.stopExecution(); break; case "typing": + if (!(await this.authorizeCommand(connection, client, "sessions.collaborate"))) break; await this.deps.clientCommands.notifyTyping(); break; case "fetch_history": @@ -137,6 +146,24 @@ export class SessionMessageRouter { } } + private async authorizeCommand( + connection: Connection, + client: Client, + permission: PermissionId + ): Promise { + const result = await this.deps.clientCommands.authorize(client, permission); + if (result === "allowed") return true; + this.deps.sockets.send(connection, { + type: "error", + code: result === "unavailable" ? "AUTHORIZATION_UNAVAILABLE" : "PERMISSION_REQUIRED", + message: + result === "unavailable" + ? "Authorization is temporarily unavailable" + : `Permission required: ${permission}`, + }); + return false; + } + private handleFetchHistory(connection: Connection, client: Client, data: FetchHistory): void { if ( !data.cursor || diff --git a/packages/control-plane/src/session/sandbox-access-reader.ts b/packages/control-plane/src/session/sandbox-access-reader.ts index 714454061..87804cb28 100644 --- a/packages/control-plane/src/session/sandbox-access-reader.ts +++ b/packages/control-plane/src/session/sandbox-access-reader.ts @@ -2,11 +2,14 @@ import type { Logger } from "../logger"; import { decryptStoredAccessValue } from "./sandbox-access"; import type { SandboxRepository } from "./sandbox-repository"; import type { SessionCoreRepository } from "./session-core-repository"; +import { resolveSandboxDashboardUrl, type SandboxDashboardSettings } from "./sandbox-access"; +import { safeParseTunnelUrls } from "./tunnel-urls"; export interface SessionAccessReaderDeps { sessionCoreRepository: SessionCoreRepository; sandboxRepository: SandboxRepository; repoSecretsEncryptionKey: string; + sandboxDashboardSettings: SandboxDashboardSettings; log: Logger; } @@ -44,7 +47,9 @@ export class SessionAccessReader { current.vnc_url !== sandbox.vnc_url || current.vnc_password !== sandbox.vnc_password || current.ttyd_url !== sandbox.ttyd_url || - current.ttyd_token !== sandbox.ttyd_token + current.ttyd_token !== sandbox.ttyd_token || + current.tunnel_urls !== sandbox.tunnel_urls || + current.modal_object_id !== sandbox.modal_object_id ) { return Response.json({ error: "Sandbox access changed; retry" }, { status: 409, headers }); } @@ -57,6 +62,13 @@ export class SessionAccessReader { vnc: current.vnc_url && vncPassword ? { url: current.vnc_url, password: vncPassword } : null, ttyd: current.ttyd_url && ttydToken ? { url: current.ttyd_url, token: ttydToken } : null, + tunnelUrls: current.tunnel_urls + ? safeParseTunnelUrls(current.tunnel_urls, this.deps.log) + : null, + sandboxDashboardUrl: resolveSandboxDashboardUrl( + this.deps.sandboxDashboardSettings, + current.modal_object_id + ), }, { headers } ); diff --git a/packages/control-plane/src/session/server.test.ts b/packages/control-plane/src/session/server.test.ts index 738de431d..70e807db1 100644 --- a/packages/control-plane/src/session/server.test.ts +++ b/packages/control-plane/src/session/server.test.ts @@ -56,6 +56,7 @@ function createHarness() { notifyTyping: vi.fn(async () => undefined), updatePresence: vi.fn(), getHistoryPage: vi.fn(() => ({ items: [], hasMore: false, cursor: null })), + authorize: vi.fn(async () => "allowed" as const), }; const sandbox: SandboxDisconnectMonitor = { getStatus: vi.fn((): "ready" => "ready"), @@ -251,6 +252,30 @@ describe("SessionServer", () => { expect(clientCommands.stopExecution).not.toHaveBeenCalled(); }); + it.each([ + [{ type: "prompt", content: "work", clientRequestId: "request-1" }, "sessions.collaborate"], + [ + { type: "cancel_prompt", messageId: "message-1", clientRequestId: "request-1" }, + "sessions.lifecycle", + ], + [{ type: "stop" }, "sessions.lifecycle"], + ] as const)("rejects %s without its command permission", async (message, permission) => { + const { server, sockets, clientCommands, client } = createHarness(); + vi.mocked(clientCommands.authorize).mockResolvedValue("denied"); + + await server.onMessage("client", JSON.stringify(message)); + + expect(clientCommands.authorize).toHaveBeenCalledWith(client, permission); + expect(sockets.send).toHaveBeenCalledWith("client", { + type: "error", + code: "PERMISSION_REQUIRED", + message: `Permission required: ${permission}`, + }); + expect(clientCommands.submitPrompt).not.toHaveBeenCalled(); + expect(clientCommands.cancelPrompt).not.toHaveBeenCalled(); + expect(clientCommands.stopExecution).not.toHaveBeenCalled(); + }); + it("routes fetch_history and enforces throttling with the injected clock", async () => { const { server, sockets, clientCommands, setNow } = createHarness(); const cursor = { timestamp: 10, id: "event-1", sequence: 2 }; diff --git a/packages/control-plane/test/integration/session-snapshot.test.ts b/packages/control-plane/test/integration/session-snapshot.test.ts index b1d0dd897..0885129cb 100644 --- a/packages/control-plane/test/integration/session-snapshot.test.ts +++ b/packages/control-plane/test/integration/session-snapshot.test.ts @@ -73,6 +73,8 @@ describe("session snapshot synchronization", () => { codeServer: { url: "https://code.example.test", password: "code-secret" }, vnc: { url: "https://desktop.example.test", password: "vnc-secret" }, ttyd: { url: "https://terminal.example.test", token: "terminal-secret" }, + tunnelUrls: null, + sandboxDashboardUrl: null, }); const { ws, messages } = await openClientWs(name, { subscribe: true }); diff --git a/packages/control-plane/test/integration/websocket-client.test.ts b/packages/control-plane/test/integration/websocket-client.test.ts index bb69566ff..01576b5ec 100644 --- a/packages/control-plane/test/integration/websocket-client.test.ts +++ b/packages/control-plane/test/integration/websocket-client.test.ts @@ -243,7 +243,7 @@ describe("Client WebSocket (via SELF.fetch)", () => { ws.close(); }); - it("rejects a custom role that cannot use the complete WebSocket protocol", async () => { + it("rejects a custom role that cannot read the session stream", async () => { const suffix = Date.now(); const name = `ws-client-partial-role-${suffix}`; const userId = `partial-role-user-${suffix}`; @@ -291,7 +291,7 @@ describe("Client WebSocket (via SELF.fetch)", () => { await expect(closed).resolves.toEqual({ code: 4010 }); }); - it("rejects a reconnect after collaborate permission is lost", async () => { + it("keeps the read stream after collaborate permission is lost but rejects prompts", async () => { const name = `ws-client-lost-permission-${Date.now()}`; const userId = `lost-permission-user-${Date.now()}`; await initNamedSession(name); @@ -303,12 +303,34 @@ describe("Client WebSocket (via SELF.fetch)", () => { .run(); const { ws } = await openClientWs(name); - const closed = new Promise<{ code: number }>((resolve) => { - ws.addEventListener("close", (event) => resolve({ code: event.code })); + const subscribed = collectMessages(ws, { + until: (message) => message.type === "subscribed", }); ws.send(JSON.stringify({ type: "subscribe", token, clientId: "lost-permission-client" })); + const snapshot = (await subscribed).find((message) => message.type === "subscribed") as Record< + string, + unknown + >; - await expect(closed).resolves.toEqual({ code: 4010 }); + expect(snapshot).toBeDefined(); + expect(snapshot.session).not.toHaveProperty("sandboxDashboardUrl"); + + const denied = collectMessages(ws, { + until: (message) => message.type === "error", + }); + ws.send( + JSON.stringify({ + type: "prompt", + clientRequestId: crypto.randomUUID(), + content: "not allowed", + }) + ); + + expect((await denied).find((message) => message.type === "error")).toMatchObject({ + code: "PERMISSION_REQUIRED", + message: "Permission required: sessions.collaborate", + }); + ws.close(); }); it("rejects a token after its canonical user is removed", async () => { diff --git a/packages/control-plane/test/integration/websocket-sandbox.test.ts b/packages/control-plane/test/integration/websocket-sandbox.test.ts index f22080f60..625c4e034 100644 --- a/packages/control-plane/test/integration/websocket-sandbox.test.ts +++ b/packages/control-plane/test/integration/websocket-sandbox.test.ts @@ -343,6 +343,8 @@ describe("Sandbox WebSocket (via SELF.fetch)", () => { codeServer: { url: "https://code.test", password: "code-secret" }, vnc: { url: "https://vnc.test", password: "vnc-secret" }, ttyd: { url: "https://terminal.test", token: "terminal-token" }, + tunnelUrls: null, + sandboxDashboardUrl: null, }); sandboxWs!.close(); diff --git a/packages/shared/src/rbac.test.ts b/packages/shared/src/rbac.test.ts index 352098523..bb2430ae3 100644 --- a/packages/shared/src/rbac.test.ts +++ b/packages/shared/src/rbac.test.ts @@ -5,6 +5,7 @@ import { PERMISSION_IDS, SCOPED_PERMISSION_PAIRS, effectiveAuthorizationSchema, + hasScopedPermission, permissionsForBuiltInRole, resolveScopedPermission, replaceMemberRoleInputSchema, @@ -81,6 +82,11 @@ describe("RBAC registry", () => { ).toBe("any"); expect(resolveScopedPermission("automations.manage", ["automations.manage.own"])).toBe("own"); expect(resolveScopedPermission("automations.manage", [])).toBeNull(); + expect(hasScopedPermission("automations.manage", ["automations.manage.any"], false)).toBe(true); + expect(hasScopedPermission("automations.manage", ["automations.manage.own"], true)).toBe(true); + expect(hasScopedPermission("automations.manage", ["automations.manage.own"], false)).toBe( + false + ); }); it("assigns every permission explicitly to Owner", () => { diff --git a/packages/shared/src/rbac.ts b/packages/shared/src/rbac.ts index 078f9d59f..6df70184a 100644 --- a/packages/shared/src/rbac.ts +++ b/packages/shared/src/rbac.ts @@ -77,12 +77,8 @@ export const PERMISSION_IDS = [ /** A permission identifier recognized by the RBAC policy. */ export type PermissionId = (typeof PERMISSION_IDS)[number]; -/** Permissions required to admit a browser WebSocket to the full session protocol. */ -export const SESSION_WEBSOCKET_PERMISSIONS = [ - "sessions.read", - "sessions.collaborate", - "sessions.lifecycle", -] as const satisfies readonly PermissionId[]; +/** Permission required to admit a browser WebSocket to the read synchronization protocol. */ +export const SESSION_WEBSOCKET_CONNECT_PERMISSION = "sessions.read" as const satisfies PermissionId; /** Maps ownership-sensitive capabilities to their workspace-wide and owner-only grants. */ export const SCOPED_PERMISSION_PAIRS = { @@ -112,6 +108,16 @@ export function resolveScopedPermission( return null; } +/** Decides a scoped resource capability from grants plus the caller's ownership result. */ +export function hasScopedPermission( + stem: ScopedPermissionStem, + permissions: readonly PermissionId[], + isOwner: boolean +): boolean { + const scope = resolveScopedPermission(stem, permissions); + return scope === "any" || (scope === "own" && isOwner); +} + const VIEWER_PERMISSIONS = new Set([ "analytics.read", "automations.read", diff --git a/packages/shared/src/types/server-messages.test.ts b/packages/shared/src/types/server-messages.test.ts index 247d7c13e..61f426c97 100644 --- a/packages/shared/src/types/server-messages.test.ts +++ b/packages/shared/src/types/server-messages.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { serverMessageSchema, sessionSnapshotSchema } from "./server-messages"; +import { + redactSessionSnapshotSandboxAccess, + serverMessageSchema, + sessionSnapshotSchema, +} from "./server-messages"; describe("artifact_updated server message", () => { const artifact = { @@ -113,6 +117,31 @@ describe("session view contracts", () => { expect(parsed.timeline.events.map((item) => item.eventId)).toEqual(["event-1"]); }); + it("redacts sandbox locations without mutating the source snapshot", () => { + const snapshot = sessionSnapshotSchema.parse({ + session: { + ...snapshotState, + codeServerUrl: "https://code.example", + vncUrl: "https://vnc.example", + ttydUrl: "https://terminal.example", + tunnelUrls: { "3000": "https://app.example" }, + sandboxDashboardUrl: "https://provider.example", + }, + artifacts: [], + promptQueue: [], + timeline: { events: [], hasMore: false, cursor: null }, + }); + + const redacted = redactSessionSnapshotSandboxAccess(snapshot); + + expect(redacted.session).not.toHaveProperty("codeServerUrl"); + expect(redacted.session).not.toHaveProperty("vncUrl"); + expect(redacted.session).not.toHaveProperty("ttydUrl"); + expect(redacted.session).not.toHaveProperty("tunnelUrls"); + expect(redacted.session).not.toHaveProperty("sandboxDashboardUrl"); + expect(snapshot.session.codeServerUrl).toBe("https://code.example"); + }); + it("rejects malformed stable event envelopes", () => { const snapshot = { session: snapshotState, diff --git a/packages/shared/src/types/server-messages.ts b/packages/shared/src/types/server-messages.ts index 316c2b5ec..432c62356 100644 --- a/packages/shared/src/types/server-messages.ts +++ b/packages/shared/src/types/server-messages.ts @@ -118,6 +118,17 @@ export const sessionSnapshotSchema = z.object({ }); export type SessionSnapshot = z.infer; +/** Removes sandbox location data before a snapshot crosses a read-only boundary. */ +export function redactSessionSnapshotSandboxAccess(snapshot: SessionSnapshot): SessionSnapshot { + const session = { ...snapshot.session }; + delete session.codeServerUrl; + delete session.vncUrl; + delete session.ttydUrl; + delete session.tunnelUrls; + delete session.sandboxDashboardUrl; + return { ...snapshot, session }; +} + const serverMessageUnionSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("pong"), timestamp: z.number() }), sessionSnapshotSchema.extend({ diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx new file mode 100644 index 000000000..cf08e8764 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +/// + +import { Suspense } from "react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import EditAutomationPage from "./page"; + +expect.extend(matchers); + +const CURRENT_USER_ID = "11111111111111111111111111111111"; +let permissions: string[] = []; +const replace = vi.fn(); + +const automation = { + id: "auto-1", + name: "Nightly review", + instructions: "Review the code", + triggerType: "schedule" as const, + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: null, + enabled: true, + nextRunAt: null, + consecutiveFailures: 0, + createdBy: CURRENT_USER_ID, + userId: "22222222222222222222222222222222", + createdAt: 1, + updatedAt: 1, + deletedAt: null, + eventType: null, + triggerConfig: null, + repositories: [], + environmentIds: [], + providerSelections: {}, +}; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace }), +})); +vi.mock("@/components/sidebar-layout", () => ({ + CollapsedSidebarControls: () => null, + useSidebarContext: () => ({ isOpen: true }), +})); +vi.mock("@/hooks/use-automations", () => ({ + useAutomation: () => ({ automation, loading: false }), +})); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + authorization: { userId: CURRENT_USER_ID, permissions }, + loading: false, + }), +})); +vi.mock("@/components/automations/automation-form", () => ({ + AutomationForm: () =>
Automation edit form
, +})); + +async function renderPage() { + await act(async () => { + render( + + + + ); + }); +} + +beforeEach(() => { + permissions = []; + replace.mockReset(); +}); +afterEach(cleanup); + +describe("EditAutomationPage authorization", () => { + it("redirects an unauthorized own-scoped deep link without rendering the form", async () => { + permissions = ["automations.manage.own"]; + await renderPage(); + + await waitFor(() => expect(replace).toHaveBeenCalledWith("/automations/auto-1")); + expect(screen.queryByText("Automation edit form")).not.toBeInTheDocument(); + }); + + it("renders the form with automations.manage.any", async () => { + permissions = ["automations.manage.any"]; + await renderPage(); + + expect(await screen.findByText("Automation edit form")).toBeInTheDocument(); + expect(replace).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx index 2224ac53f..0443a31ae 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, use } from "react"; +import { useEffect, useState, use } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; @@ -12,14 +12,26 @@ import { import { ErrorBanner } from "@/components/ui/error-banner"; import { BackIcon } from "@/components/ui/icons"; import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { canAccessAutomation } from "@/lib/automation-authorization"; export default function EditAutomationPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const { isOpen } = useSidebarContext(); const router = useRouter(); const { automation, loading } = useAutomation(id); + const { authorization, loading: authorizationLoading } = useCurrentUserAuthorization(); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); + const canManage = automation + ? canAccessAutomation("automations.manage", authorization, automation) + : false; + + useEffect(() => { + if (!loading && !authorizationLoading && automation && !canManage) { + router.replace(`/automations/${id}`); + } + }, [automation, authorizationLoading, canManage, id, loading, router]); const handleSubmit = async (values: AutomationFormValues) => { setSubmitting(true); @@ -45,7 +57,7 @@ export default function EditAutomationPage({ params }: { params: Promise<{ id: s } }; - if (loading) { + if (loading || authorizationLoading) { return (
@@ -66,6 +78,8 @@ export default function EditAutomationPage({ params }: { params: Promise<{ id: s ); } + if (!canManage) return null; + return (
{!isOpen && ( diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx new file mode 100644 index 000000000..a877f4b02 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +/// + +import { Suspense } from "react"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AutomationDetailPage from "./page"; + +expect.extend(matchers); + +const CURRENT_USER_ID = "11111111111111111111111111111111"; +const OTHER_USER_ID = "22222222222222222222222222222222"; +let permissions: string[] = []; + +const automation = { + id: "auto-1", + name: "Nightly review", + instructions: "Review the code", + triggerType: "schedule" as const, + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: null, + enabled: true, + nextRunAt: null, + consecutiveFailures: 0, + createdBy: CURRENT_USER_ID, + userId: OTHER_USER_ID, + createdAt: 1, + updatedAt: 1, + deletedAt: null, + eventType: null, + triggerConfig: null, + repositories: [], + environmentIds: [], + providerSelections: {}, +}; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock("next/link", () => ({ + default: ({ children, ...props }: React.ComponentProps<"a">) => {children}, +})); +vi.mock("@/components/sidebar-layout", () => ({ + CollapsedSidebarControls: () => null, + useSidebarContext: () => ({ isOpen: true }), +})); +vi.mock("@/hooks/use-automations", () => ({ + useAutomation: () => ({ automation, loading: false, mutate: vi.fn() }), + useAutomationInvocations: () => ({ + invocations: [], + total: 0, + loading: false, + mutate: vi.fn(), + }), +})); +vi.mock("@/hooks/use-environments", () => ({ + useEnvironments: () => ({ environments: [] }), +})); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + authorization: { + userId: CURRENT_USER_ID, + permissions, + }, + }), +})); +vi.mock("@/components/automations/run-history", () => ({ RunHistory: () => null })); + +async function renderPage() { + await act(async () => { + render( + + + + ); + }); +} + +beforeEach(() => { + permissions = []; +}); +afterEach(cleanup); + +describe("AutomationDetailPage authorization", () => { + it("does not treat createdBy provenance as canonical ownership", async () => { + permissions = ["automations.manage.own", "automations.trigger.own"]; + await renderPage(); + await screen.findByRole("heading", { name: "Nightly review" }); + + expect(screen.queryByRole("link", { name: /edit/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Trigger Now" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Pause" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); + }); + + it("shows manage and trigger controls with any-scoped capabilities", async () => { + permissions = ["automations.manage.any", "automations.trigger.any"]; + await renderPage(); + await screen.findByRole("heading", { name: "Nightly review" }); + + expect(screen.getByRole("link", { name: /edit/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Trigger Now" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx index 49bc584ec..bd718936c 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx @@ -17,6 +17,8 @@ import { BackIcon, PencilIcon } from "@/components/ui/icons"; import { formatModelNameLower } from "@/lib/format"; import { formatAutomationTargetsLabel } from "@/lib/repo-label"; import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { canAccessAutomation } from "@/lib/automation-authorization"; const HISTORY_PAGE_SIZE = 20; @@ -25,6 +27,7 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: const { isOpen } = useSidebarContext(); const router = useRouter(); const { automation, loading, mutate } = useAutomation(id); + const { authorization } = useCurrentUserAuthorization(); const { environments } = useEnvironments(); // "Load more" grows the fetch limit rather than paging by offset: the // endpoint returns newest-first, so a larger limit re-fetches the head plus @@ -96,6 +99,9 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: ); } + const canManage = canAccessAutomation("automations.manage", authorization, automation); + const canTrigger = canAccessAutomation("automations.trigger", authorization, automation); + return (
{!isOpen && ( @@ -139,70 +145,76 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id:

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

Automations

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

Welcome to {APP_NAME}

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

Ask a question or describe what you want to build

+ ) : isAuthenticated ? ( +

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

) : (

Sign in to start a new session

)}
{/* Input box - only show when authenticated */} - {isAuthenticated && ( + {isAuthenticated && canCreateSession && (
{error && {error}} diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx index 9fa0295de..91192a80a 100644 --- a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx @@ -63,6 +63,8 @@ import { usePromptInput } from "@/hooks/use-prompt-input"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useSessionSnapshot } from "./session-snapshot-provider"; import { useSessionRename } from "@/hooks/use-session-rename"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { resolveSessionCapabilities } from "@/lib/session-capabilities"; type SessionState = ReturnType["sessionState"]; @@ -71,6 +73,8 @@ const DEFAULT_SESSION_STATUS = "created" as const; export default function SessionPage() { const { shortcuts } = useKeyboardShortcuts(); + const { hasPermission } = useCurrentUserAuthorization(); + const capabilities = useMemo(() => resolveSessionCapabilities(hasPermission), [hasPermission]); const initialSnapshot = useSessionSnapshot(); const sessionId = initialSnapshot.session.id; const { @@ -95,7 +99,7 @@ export default function SessionPage() { sendTyping, reconnect, loadOlderEvents, - } = useSessionSocket(sessionId, initialSnapshot); + } = useSessionSocket(sessionId, initialSnapshot, capabilities); const { profiles, participants: profiledParticipants } = useSessionParticipantProfiles( sessionId, participants, @@ -143,13 +147,14 @@ export default function SessionPage() { reasoningEffort, loadingEnabledModels, sessionState?.status ?? DEFAULT_SESSION_STATUS, - ready, + ready && capabilities.collaborate, shortcuts["send-prompt"] ); const [cancellingPromptIds, setCancellingPromptIds] = useState>(new Set()); const cancellingPromptIdsRef = useRef(new Set()); const handleRemoveQueuedPrompt = useCallback( async (messageId: string) => { + if (!capabilities.lifecycle) return; if (cancellingPromptIdsRef.current.has(messageId)) return; const queuedPrompt = promptQueue.find((item) => item.messageId === messageId); if (!queuedPrompt || queuedPrompt.status !== "pending") return; @@ -175,7 +180,7 @@ export default function SessionPage() { setCancellingPromptIds(new Set(cancellingPromptIdsRef.current)); } }, - [cancelPrompt, promptQueue, restorePrompt, setSubmitError] + [cancelPrompt, capabilities.lifecycle, promptQueue, restorePrompt, setSubmitError] ); const [selectedMediaArtifactId, setSelectedMediaArtifactId] = useState(null); @@ -217,7 +222,13 @@ export default function SessionPage() { }, [applyTerminalOpen]); const ttydUrl = sessionState?.ttydUrl; const ttydToken = sessionState?.ttydToken; - const showTerminal = !!(ttydUrl && ttydToken && terminalOpen && !isBelowLg); + const showTerminal = !!( + capabilities.sandboxAccess && + ttydUrl && + ttydToken && + terminalOpen && + !isBelowLg + ); const toggleDetails = useCallback(() => { setIsDetailsOpen((prev) => !prev); @@ -355,44 +366,48 @@ export default function SessionPage() { promptQueue={promptQueue} cancellingPromptIds={cancellingPromptIds} onRemove={handleRemoveQueuedPrompt} + capabilities={capabilities} /> - + {capabilities.collaborate && ( + + )}
); @@ -419,13 +434,15 @@ export default function SessionPage() { primaryRepo, onArchive: handleArchive, onUnarchive: handleUnarchive, + capabilities, }} optimisticTitle={optimisticTitle} renameSession={renameSession} + capabilities={capabilities} /> {/* Connection error banner */} - {(authError || connectionError) && ( + {capabilities.read && (authError || connectionError) && (

{authError || connectionError}

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

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

-
- - -
+ {canCreate && ( +
+ + +
+ )}
); } @@ -127,105 +137,122 @@ export function AutomationsList({ return ( <>
- {automations.map((automation) => ( -
- {/* Header: Name + badge | Actions */} -
-
- - {automation.name} - - - -
-
- {automation.enabled ? ( - - ) : ( - + {automations.map((automation) => { + const canManage = canAccessAutomation("automations.manage", authorization, automation); + const canTrigger = canAccessAutomation("automations.trigger", authorization, automation); + return ( +
+ {/* Header: Name + badge | Actions */} +
+
+ + {automation.name} + + + +
+
+ {canManage && + (automation.enabled ? ( + + ) : ( + + ))} + {canTrigger && ( + + )} + {canManage && ( + + )} +
+ {(canManage || canTrigger) && ( + + + + + + {canManage && ( + + automation.enabled ? onPause(automation.id) : onResume(automation.id) + } + > + {automation.enabled ? "Pause" : "Resume"} + + )} + {canTrigger && ( + onTrigger(automation.id)}> + + )} + {canManage && ( + setConfirmDeleteId(automation.id)} + > + Delete + + )} + + )} - -
- - - - - - - automation.enabled ? onPause(automation.id) : onResume(automation.id) - } - > - {automation.enabled ? "Pause" : "Resume"} - - onTrigger(automation.id)}> - - setConfirmDeleteId(automation.id)} - > - Delete - - - -
- {/* Metadata: icon-paired items */} -
- - {automation.environmentIds.length > 0 && automation.repositories.length === 0 ? ( - - -
-
- ))} + ); + })}
{ cleanup(); @@ -14,7 +22,14 @@ describe("DiffRetryNotice", () => { it("retries through the explicit retry endpoint from the banner variant", async () => { const fetchMock = vi.fn().mockResolvedValue(Response.json({}, { status: 200 })); vi.stubGlobal("fetch", fetchMock); - render(); + render( + + ); expect(screen.getByText("timed out")).toBeVisible(); await userEvent.click(screen.getByRole("button", { name: "Retry" })); @@ -30,7 +45,14 @@ describe("DiffRetryNotice", () => { .fn() .mockResolvedValue(Response.json({ error: "Sandbox is not connected" }, { status: 409 })); vi.stubGlobal("fetch", fetchMock); - render(); + render( + + ); await userEvent.click(screen.getByRole("button", { name: "Retry" })); expect(await screen.findByRole("alert")).toHaveTextContent("Sandbox is not connected"); @@ -41,7 +63,14 @@ describe("DiffRetryNotice", () => { .fn() .mockResolvedValue(Response.json({ error: "Still failing" }, { status: 500 })); vi.stubGlobal("fetch", fetchMock); - render(); + render( + + ); expect(screen.getByText("capture failed")).toBeVisible(); await userEvent.click(screen.getByRole("button", { name: "Retry" })); @@ -52,4 +81,17 @@ describe("DiffRetryNotice", () => { }); expect(await screen.findByRole("alert")).toHaveTextContent("Still failing"); }); + + it("hides retry without lifecycle permission even when collaboration is allowed", () => { + render( + + ); + + expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); + }); }); diff --git a/packages/web/src/components/diff-retry-notice.tsx b/packages/web/src/components/diff-retry-notice.tsx index 884464e8f..a8a60c277 100644 --- a/packages/web/src/components/diff-retry-notice.tsx +++ b/packages/web/src/components/diff-retry-notice.tsx @@ -2,6 +2,7 @@ import { useSessionDiffRetry } from "@/hooks/use-session-diffs"; import { cn } from "@/lib/utils"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; /** * Diff refresh failure notice with a retry action. One render tree for both @@ -13,10 +14,12 @@ export function DiffRetryNotice({ sessionId, message, variant, + capabilities, }: { sessionId: string; message: string; variant: "banner" | "inline"; + capabilities: SessionCapabilities; }) { const { retry, isRetrying, retryError } = useSessionDiffRetry(sessionId); const banner = variant === "banner"; @@ -33,17 +36,19 @@ export function DiffRetryNotice({

{message}

- + {capabilities.lifecycle && ( + + )}
{retryError && (

Copy link - - - - {controls.isArchived ? "Unarchive" : "Archive"} - + {actions.capabilities.lifecycle && } + {actions.capabilities.lifecycle && ( + + + {controls.isArchived ? "Unarchive" : "Archive"} + + )}

- + {actions.capabilities.lifecycle && ( + + )} ); } diff --git a/packages/web/src/components/queued-prompt-stack.test.tsx b/packages/web/src/components/queued-prompt-stack.test.tsx index 95db55c53..199f283ef 100644 --- a/packages/web/src/components/queued-prompt-stack.test.tsx +++ b/packages/web/src/components/queued-prompt-stack.test.tsx @@ -5,17 +5,39 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { QueuedPromptStack } from "./queued-prompt-stack"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; + +const FULL_CAPABILITIES = { + read: true, + collaborate: true, + lifecycle: true, + sandboxAccess: true, +} satisfies SessionCapabilities; expect.extend(matchers); afterEach(cleanup); describe("QueuedPromptStack", () => { + it("shows queued prompts without removal controls in read-only mode", () => { + render( + + ); + + expect(screen.getByText("Review this")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Remove queued prompt/ })).not.toBeInTheDocument(); + }); it("renders only pending prompts in FIFO order", () => { render( { ); @@ -51,6 +74,7 @@ describe("QueuedPromptStack", () => { promptQueue={[{ messageId: "next", content: "Run next", status: "pending" }]} cancellingPromptIds={new Set(["next"])} onRemove={onRemove} + capabilities={FULL_CAPABILITIES} /> ); @@ -67,6 +91,7 @@ describe("QueuedPromptStack", () => { promptQueue={[{ messageId: "next", content: "Run next", status: "pending" }]} cancellingPromptIds={new Set()} onRemove={onRemove} + capabilities={FULL_CAPABILITIES} /> ); @@ -87,6 +112,7 @@ describe("QueuedPromptStack", () => { ]} cancellingPromptIds={new Set()} onRemove={onRemove} + capabilities={FULL_CAPABILITIES} /> ); diff --git a/packages/web/src/components/queued-prompt-stack.tsx b/packages/web/src/components/queued-prompt-stack.tsx index 9d8c7cc35..60e4a7fd6 100644 --- a/packages/web/src/components/queued-prompt-stack.tsx +++ b/packages/web/src/components/queued-prompt-stack.tsx @@ -2,15 +2,18 @@ import { ClockIcon, XIcon } from "@/components/ui/icons"; import type { PromptQueueItem } from "@open-inspect/shared/types/server-messages"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; export function QueuedPromptStack({ promptQueue, cancellingPromptIds, onRemove, + capabilities, }: { promptQueue: PromptQueueItem[]; cancellingPromptIds: ReadonlySet; onRemove: (messageId: string) => void; + capabilities: SessionCapabilities; }) { const pendingPrompts = promptQueue.filter((item) => item.status === "pending"); if (pendingPrompts.length === 0) return null; @@ -28,16 +31,18 @@ export function QueuedPromptStack({

{prompt.content}

- + {capabilities.lifecycle && ( + + )} ))} diff --git a/packages/web/src/components/session-actions.ts b/packages/web/src/components/session-actions.ts index 774a757c5..a95e8c828 100644 --- a/packages/web/src/components/session-actions.ts +++ b/packages/web/src/components/session-actions.ts @@ -9,6 +9,7 @@ import { truncateBranch } from "@/lib/format"; import { listPrArtifacts } from "@/lib/pr-artifacts"; import { getSafeExternalUrl } from "@/lib/urls"; import type { Artifact } from "@/types/session"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; export interface SessionActionProps { sessionId: string; @@ -18,6 +19,7 @@ export interface SessionActionProps { primaryRepo?: { repoOwner: string; repoName: string } | null; onArchive?: () => void | Promise; onUnarchive?: () => void | Promise; + capabilities: SessionCapabilities; } /** One PR a session-level action can open, ready to render as a link. */ diff --git a/packages/web/src/components/session-changes-panel.test.tsx b/packages/web/src/components/session-changes-panel.test.tsx index 49ddab842..7c85c05cf 100644 --- a/packages/web/src/components/session-changes-panel.test.tsx +++ b/packages/web/src/components/session-changes-panel.test.tsx @@ -4,6 +4,14 @@ import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SessionDiffState } from "@open-inspect/shared/types/session-diffs"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; + +const FULL_CAPABILITIES = { + read: true, + collaborate: true, + lifecycle: true, + sandboxAccess: true, +} satisfies SessionCapabilities; vi.mock("next/dynamic", () => ({ default: () => () =>
, @@ -69,6 +77,7 @@ describe("SessionChangesPanel", () => { render( { render( { render( { render( { { render( void; onSelect: (selection: DiffSelection) => void; mobile?: boolean; + capabilities: SessionCapabilities; }) { const panelRef = useRef(null); const fileListId = useId(); @@ -308,7 +311,12 @@ export function SessionChangesPanel({ /> {state.lastError && ( - + )}
diff --git a/packages/web/src/components/session-details-overlay.tsx b/packages/web/src/components/session-details-overlay.tsx index 9487ff9fe..0be94c7af 100644 --- a/packages/web/src/components/session-details-overlay.tsx +++ b/packages/web/src/components/session-details-overlay.tsx @@ -47,6 +47,7 @@ export function SessionDetailsOverlay({ diffLoading, selectedDiff, onOpenDiff, + capabilities, }: SessionDetailsOverlayProps) { const [sheetDragY, setSheetDragY] = useState(0); const sheetDragYRef = useRef(0); @@ -175,6 +176,7 @@ export function SessionDetailsOverlay({ diffLoading={diffLoading} selectedDiff={selectedDiff} onOpenDiff={onOpenDiff} + capabilities={capabilities} /> ); diff --git a/packages/web/src/components/session-header.test.tsx b/packages/web/src/components/session-header.test.tsx index e215c7923..4891b1331 100644 --- a/packages/web/src/components/session-header.test.tsx +++ b/packages/web/src/components/session-header.test.tsx @@ -1,13 +1,14 @@ // @vitest-environment jsdom /// -import { createRef } from "react"; +import { createRef, type ComponentProps } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; import type { SessionState } from "@open-inspect/shared/types/server-messages"; -import { SessionHeader } from "./session-header"; +import { SessionHeader as SessionHeaderComponent } from "./session-header"; import type { SessionActionProps } from "./session-actions"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; expect.extend(matchers); @@ -20,10 +21,27 @@ vi.mock("@/components/sidebar-layout", () => ({ afterEach(cleanup); +const FULL_CAPABILITIES: SessionCapabilities = { + read: true, + collaborate: true, + lifecycle: true, + sandboxAccess: true, +}; + +function SessionHeader({ + capabilities = FULL_CAPABILITIES, + ...props +}: Omit, "capabilities"> & { + capabilities?: SessionCapabilities; +}) { + return ; +} + const actions: SessionActionProps = { sessionId: "session-1", sessionStatus: "active", artifacts: [], + capabilities: FULL_CAPABILITIES, }; function createSessionState(overrides: Partial = {}): SessionState { @@ -43,6 +61,40 @@ function createSessionState(overrides: Partial = {}): SessionState } describe("SessionHeader", () => { + it("disables lifecycle controls and connection UI for a read-only session", async () => { + render( + ()} + actionsButtonRef={createRef()} + onToggleDetails={vi.fn()} + onToggleDesktopDetails={vi.fn()} + onOpenMobileDetails={vi.fn()} + actions={{ ...actions, capabilities: { ...FULL_CAPABILITIES, lifecycle: false } }} + renameSession={vi.fn()} + capabilities={{ + read: false, + collaborate: false, + lifecycle: false, + sandboxAccess: false, + }} + /> + ); + + expect(screen.getByRole("button", { name: "Session 1" })).toBeDisabled(); + expect(screen.queryByRole("status", { name: /Connection status/ })).not.toBeInTheDocument(); + + const trigger = screen.getByRole("button", { name: "Session actions" }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false }); + expect(screen.queryByRole("menuitem", { name: "Archive" })).not.toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Copy link" })).toBeInTheDocument(); + }); it("lets desktop users hide and show the session details sidebar", () => { const onToggleDesktopDetails = vi.fn(); const { rerender } = render( diff --git a/packages/web/src/components/session-header.tsx b/packages/web/src/components/session-header.tsx index 5e9784a00..bc377e8a5 100644 --- a/packages/web/src/components/session-header.tsx +++ b/packages/web/src/components/session-header.tsx @@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import type { useSessionSocket } from "@/hooks/use-session-socket"; import { formatRepoLabel } from "@/lib/repo-label"; import { getSafeExternalUrl } from "@/lib/urls"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; type SessionSocketState = ReturnType; @@ -100,6 +101,7 @@ export type SessionHeaderProps = { actions: SessionActionProps; optimisticTitle?: string; renameSession: (title: string) => Promise; + capabilities: SessionCapabilities; }; export function SessionHeader({ @@ -119,6 +121,7 @@ export function SessionHeader({ actions, optimisticTitle, renameSession, + capabilities, }: SessionHeaderProps) { const { isOpen } = useSidebarContext(); const hasFallbackSessionInfo = @@ -138,6 +141,7 @@ export function SessionHeader({ optimisticTitle ?? sessionState?.title ?? fallbackSessionInfo.title ?? repoLabel; const handleStartRename = () => { + if (!capabilities.lifecycle) return; setTitle(resolvedTitle); setIsRenaming(true); }; @@ -196,9 +200,10 @@ export function SessionHeader({

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

+ )}
- + {canManageLifecycle && ( + + )} ); } diff --git a/packages/web/src/components/session-prompt-composer.test.tsx b/packages/web/src/components/session-prompt-composer.test.tsx index fbe35398d..f593c8234 100644 --- a/packages/web/src/components/session-prompt-composer.test.tsx +++ b/packages/web/src/components/session-prompt-composer.test.tsx @@ -8,9 +8,17 @@ import userEvent from "@testing-library/user-event"; import * as matchers from "@testing-library/jest-dom/matchers"; import { SessionPromptComposer } from "./session-prompt-composer"; import { MAX_WEB_PROMPT_CHARS } from "@open-inspect/shared/types/websocket"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; expect.extend(matchers); +const FULL_CAPABILITIES: SessionCapabilities = { + read: true, + collaborate: true, + lifecycle: true, + sandboxAccess: true, +}; + vi.mock("@/components/action-bar", () => ({ ActionBar: () =>
, })); @@ -38,6 +46,7 @@ function ComposerHarness({ status = "active", submitError = null, withSkill = false, + canManageLifecycle = true, }: { initialValue?: string; isProcessing?: boolean; @@ -46,6 +55,7 @@ function ComposerHarness({ status?: "active" | "archived" | "cancelled"; submitError?: string | null; withSkill?: boolean; + canManageLifecycle?: boolean; }) { const [value, setValue] = useState(initialValue); const inputRef = useRef(null); @@ -58,6 +68,7 @@ function ComposerHarness({ artifacts: [], onArchive: vi.fn(), onUnarchive: vi.fn(), + capabilities: { ...FULL_CAPABILITIES, lifecycle: canManageLifecycle }, }} prompt={{ value, @@ -172,6 +183,16 @@ describe("SessionPromptComposer", () => { expect(screen.getByTitle(/Send/)).toBeDisabled(); }); + it("hides stop controls without lifecycle permission", () => { + render( + + ); + + expect( + screen.queryByTitle("Stop current prompt; queued prompts will continue") + ).not.toBeInTheDocument(); + }); + it("shows an inline submission error", () => { render(); expect(screen.getByRole("alert")).toHaveTextContent("The prompt queue is full"); diff --git a/packages/web/src/components/session-prompt-composer.tsx b/packages/web/src/components/session-prompt-composer.tsx index 675617be1..9b46f5915 100644 --- a/packages/web/src/components/session-prompt-composer.tsx +++ b/packages/web/src/components/session-prompt-composer.tsx @@ -15,6 +15,7 @@ import type { SessionStatus } from "@open-inspect/shared/types/sessions"; import { MAX_WEB_PROMPT_CHARS } from "@open-inspect/shared/types/websocket"; import type { PromptSkillSuggestionSource } from "@/lib/prompt-skill-completion"; import type { ModelCategory, ReasoningEffort, ValidModel } from "@open-inspect/shared/models"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; type SessionPromptComposerProps = { session: { @@ -24,6 +25,7 @@ type SessionPromptComposerProps = { primaryRepo?: { repoOwner: string; repoName: string } | null; onArchive: () => void | Promise; onUnarchive: () => void | Promise; + capabilities: SessionCapabilities; }; prompt: { value: string; @@ -105,6 +107,7 @@ export function SessionPromptComposer({ primaryRepo={session.primaryRepo} onArchive={session.onArchive} onUnarchive={session.onUnarchive} + capabilities={session.capabilities} />
@@ -165,7 +168,7 @@ export function SessionPromptComposer({ > - {prompt.isProcessing && ( + {prompt.isProcessing && session.capabilities.lifecycle && (
{/* Code Server */} - {sessionState.codeServerUrl && ( + {capabilities.sandboxAccess && sessionState.codeServerUrl && (
@@ -182,14 +186,16 @@ export function SessionRightSidebarContent({ )} {/* Tunnel URLs */} - {sessionState.tunnelUrls && Object.keys(sessionState.tunnelUrls).length > 0 && ( -
- -
- )} + {capabilities.sandboxAccess && + sessionState.tunnelUrls && + Object.keys(sessionState.tunnelUrls).length > 0 && ( +
+ +
+ )} {/* Tasks */} {tasks.length > 0 && ( @@ -243,6 +249,7 @@ export function SessionRightSidebarContent({ sessionId={sessionId} message={diffView.message ?? ""} variant="inline" + capabilities={capabilities} /> )}
@@ -287,6 +294,7 @@ export function SessionRightSidebar({ diffLoading, selectedDiff, onOpenDiff, + capabilities, }: SessionRightSidebarProps) { return ( ); diff --git a/packages/web/src/components/session-sidebar.tsx b/packages/web/src/components/session-sidebar.tsx index 1394a1adc..b4bee5278 100644 --- a/packages/web/src/components/session-sidebar.tsx +++ b/packages/web/src/components/session-sidebar.tsx @@ -70,10 +70,10 @@ export function SessionSidebar({ }: SessionSidebarProps) { const { labels } = useKeyboardShortcuts(); const { data: authSession } = useAuthSession(); + const { hasPermission } = useCurrentUserAuthorization(); const pathname = usePathname(); const router = useRouter(); const isMobile = useIsMobile(); - const { hasPermission } = useCurrentUserAuthorization(); const currentSessionId = pathname?.startsWith("/session/") ? pathname.split("/")[2] : null; diff --git a/packages/web/src/components/sidebar-layout.test.tsx b/packages/web/src/components/sidebar-layout.test.tsx index 587fb1e3a..75eb99ccd 100644 --- a/packages/web/src/components/sidebar-layout.test.tsx +++ b/packages/web/src/components/sidebar-layout.test.tsx @@ -11,6 +11,7 @@ expect.extend(matchers); const mocks = vi.hoisted(() => ({ isMobile: false, + canCreateSession: true, sidebar: { isOpen: true, toggle: vi.fn(), @@ -32,10 +33,18 @@ vi.mock("@/hooks/use-sidebar", () => ({ useSidebar: () => mocks.sidebar, })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + permission === "sessions.create" && mocks.canCreateSession, + }), +})); + afterEach(() => { cleanup(); vi.clearAllMocks(); mocks.isMobile = false; + mocks.canCreateSession = true; mocks.sidebar.isOpen = true; }); @@ -63,6 +72,19 @@ describe("CollapsedSidebarControls", () => { fireEvent.click(buttons![2]); expect(push).toHaveBeenCalledWith("/"); }); + + it("hides the new session action without session creation permission", () => { + mocks.canCreateSession = false; + vi.mocked(useRouter).mockReturnValue({ push: vi.fn() } as never); + + render( + + + + ); + + expect(screen.queryByRole("button", { name: /New session/ })).not.toBeInTheDocument(); + }); }); describe("mobile sidebar drag", () => { diff --git a/packages/web/src/components/sidebar-layout.tsx b/packages/web/src/components/sidebar-layout.tsx index 5c6ecfcaf..e7b385fc9 100644 --- a/packages/web/src/components/sidebar-layout.tsx +++ b/packages/web/src/components/sidebar-layout.tsx @@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"; import { SidebarIcon } from "@/components/ui/icons"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useMobileSidebarPull } from "@/hooks/use-mobile-sidebar-pull"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; interface SidebarContextValue { isOpen: boolean; @@ -60,6 +61,7 @@ export function SidebarToggleButton({ label = "Open sidebar" }: { label?: string export function CollapsedSidebarControls() { const actions = useContext(AppShellActionsContext); + const { hasPermission } = useCurrentUserAuthorization(); if (!actions) { throw new Error("CollapsedSidebarControls must be used within a SidebarLayout"); } @@ -68,13 +70,15 @@ export function CollapsedSidebarControls() {
- + {hasPermission("sessions.create") && }
); } export function SidebarLayout({ children }: SidebarLayoutProps) { const router = useRouter(); + const { hasPermission } = useCurrentUserAuthorization(); + const canCreateSession = hasPermission("sessions.create"); const sidebar = useSidebar(); const isMobile = useIsMobile(); const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false); @@ -95,12 +99,13 @@ export function SidebarLayout({ children }: SidebarLayoutProps) { ); const handleNewSession = useCallback(() => { + if (!canCreateSession) return; setIsCommandMenuOpen(false); if (isMobile) { sidebar.close(); } router.push("/"); - }, [isMobile, router, sidebar]); + }, [canCreateSession, isMobile, router, sidebar]); const handleNavigate = useCallback( (href: string) => { diff --git a/packages/web/src/components/sidebar/metadata-section.test.tsx b/packages/web/src/components/sidebar/metadata-section.test.tsx index d02b00141..8a5573f63 100644 --- a/packages/web/src/components/sidebar/metadata-section.test.tsx +++ b/packages/web/src/components/sidebar/metadata-section.test.tsx @@ -1,10 +1,11 @@ // @vitest-environment jsdom /// +import type { ComponentProps } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; -import { MetadataSection } from "./metadata-section"; +import { MetadataSection as MetadataSectionComponent } from "./metadata-section"; expect.extend(matchers); @@ -13,6 +14,15 @@ expect.extend(matchers); // matching leftover DOM from earlier renders. afterEach(cleanup); +function MetadataSection({ + canManageLifecycle = true, + ...props +}: Omit, "canManageLifecycle"> & { + canManageLifecycle?: boolean; +}) { + return ; +} + vi.mock("next/link", () => ({ default: ({ children, href, ...props }: React.ComponentProps<"a">) => ( diff --git a/packages/web/src/components/sidebar/metadata-section.tsx b/packages/web/src/components/sidebar/metadata-section.tsx index 9b16187a0..f17d74f6c 100644 --- a/packages/web/src/components/sidebar/metadata-section.tsx +++ b/packages/web/src/components/sidebar/metadata-section.tsx @@ -52,6 +52,7 @@ interface MetadataSectionProps { warnings?: WarningEvent[]; parentSessionId?: string | null; totalCost?: number; + canManageLifecycle: boolean; } /** @@ -108,12 +109,13 @@ export function MetadataSection({ warnings = [], parentSessionId, totalCost, + canManageLifecycle, }: MetadataSectionProps) { const [copied, setCopied] = useState(false); const isMultiRepo = (repositories?.length ?? 0) > 1; const hasPrArtifact = artifacts.some((a) => a.type === "pr"); - const showSyncButton = Boolean(sessionId) && hasPrArtifact; + const showSyncButton = canManageLifecycle && Boolean(sessionId) && hasPrArtifact; // Sessions can hold several PRs (one open PR per head branch); list them // all, oldest first — creation order matches PR-number order. diff --git a/packages/web/src/hooks/use-global-shortcuts.test.tsx b/packages/web/src/hooks/use-global-shortcuts.test.tsx index 68b8b24ef..3b0507bcc 100644 --- a/packages/web/src/hooks/use-global-shortcuts.test.tsx +++ b/packages/web/src/hooks/use-global-shortcuts.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_KEYBOARD_SHORTCUTS } from "@open-inspect/shared/types/keyboard-shortcuts"; import { useGlobalShortcuts } from "./use-global-shortcuts"; +const mocks = vi.hoisted(() => ({ canCreateSession: true })); + const shortcuts = { ...DEFAULT_KEYBOARD_SHORTCUTS, "open-command-menu": { code: "KeyP", primary: true, alt: false, shift: false }, @@ -16,8 +18,18 @@ vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ useKeyboardShortcuts: () => ({ shortcuts }), })); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + permission === "sessions.create" && mocks.canCreateSession, + }), +})); + describe("useGlobalShortcuts", () => { - afterEach(() => vi.restoreAllMocks()); + afterEach(() => { + mocks.canCreateSession = true; + vi.restoreAllMocks(); + }); it("dispatches the configured action and removes its listener", () => { const onOpenCommandMenu = vi.fn(); @@ -46,4 +58,27 @@ describe("useGlobalShortcuts", () => { window.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyP", ctrlKey: true })); expect(onOpenCommandMenu).toHaveBeenCalledOnce(); }); + + it("ignores the new session shortcut without session creation permission", () => { + mocks.canCreateSession = false; + const onNewSession = vi.fn(); + renderHook(() => + useGlobalShortcuts({ + onOpenCommandMenu: vi.fn(), + onNewSession, + onToggleSidebar: vi.fn(), + }) + ); + + const event = new KeyboardEvent("keydown", { + code: "KeyN", + ctrlKey: true, + shiftKey: true, + cancelable: true, + }); + window.dispatchEvent(event); + + expect(onNewSession).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); }); diff --git a/packages/web/src/hooks/use-global-shortcuts.ts b/packages/web/src/hooks/use-global-shortcuts.ts index 33446518c..2f5d41355 100644 --- a/packages/web/src/hooks/use-global-shortcuts.ts +++ b/packages/web/src/hooks/use-global-shortcuts.ts @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { matchGlobalShortcut, shouldIgnoreGlobalShortcutForAction } from "@/lib/keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; interface UseGlobalShortcutsOptions { enabled?: boolean; @@ -18,6 +19,8 @@ export function useGlobalShortcuts({ onToggleSidebar, }: UseGlobalShortcutsOptions) { const { shortcuts } = useKeyboardShortcuts(); + const { hasPermission } = useCurrentUserAuthorization(); + const canCreateSession = hasPermission("sessions.create"); useEffect(() => { if (!enabled) return; @@ -25,6 +28,7 @@ export function useGlobalShortcuts({ const action = matchGlobalShortcut(event, shortcuts); if (!action) return; if (shouldIgnoreGlobalShortcutForAction(event, action)) return; + if (action === "new-session" && !canCreateSession) return; event.preventDefault(); @@ -35,5 +39,5 @@ export function useGlobalShortcuts({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [enabled, onNewSession, onOpenCommandMenu, onToggleSidebar, shortcuts]); + }, [canCreateSession, enabled, onNewSession, onOpenCommandMenu, onToggleSidebar, shortcuts]); } diff --git a/packages/web/src/hooks/use-sandbox-access.ts b/packages/web/src/hooks/use-sandbox-access.ts index d28b3a1d5..f814a6505 100644 --- a/packages/web/src/hooks/use-sandbox-access.ts +++ b/packages/web/src/hooks/use-sandbox-access.ts @@ -10,22 +10,29 @@ const sandboxAccessSchema = z codeServer: z.object({ url: z.string(), password: z.string() }).nullable(), vnc: z.object({ url: z.string(), password: z.string() }).nullable(), ttyd: z.object({ url: z.string(), token: z.string() }).nullable(), + // Optional during rolling deployments where the control plane predates + // these protected access fields. + tunnelUrls: z.record(z.string(), z.string()).nullable().optional().default(null), + sandboxDashboardUrl: z.string().nullable().optional().default(null), }) - .transform(({ codeServer, vnc, ttyd }) => ({ + .transform(({ codeServer, vnc, ttyd, tunnelUrls, sandboxDashboardUrl }) => ({ codeServerUrl: codeServer?.url ?? null, codeServerPassword: codeServer?.password ?? null, vncUrl: vnc?.url ?? null, vncPassword: vnc?.password ?? null, ttydUrl: ttyd?.url ?? null, ttydToken: ttyd?.token ?? null, + tunnelUrls, + sandboxDashboardUrl, })); type SandboxAccess = z.infer; -export function useSandboxAccess(sessionId: string, isSandboxReady: boolean) { - const key: BrowserApiPath | null = isSandboxReady - ? `/api/sessions/${encodeURIComponent(sessionId)}/sandbox-access` - : null; +export function useSandboxAccess(sessionId: string, isSandboxReady: boolean, enabled = true) { + const key: BrowserApiPath | null = + enabled && isSandboxReady + ? `/api/sessions/${encodeURIComponent(sessionId)}/sandbox-access` + : null; const { data, mutate } = useSWR(key, async (url: BrowserApiPath) => { const response = await browserApiFetch(url, { cache: "no-store" }); if (response.status === 204 || response.status === 404) return null; diff --git a/packages/web/src/hooks/use-session-socket.test.tsx b/packages/web/src/hooks/use-session-socket.test.tsx index 477625509..7a0347ab9 100644 --- a/packages/web/src/hooks/use-session-socket.test.tsx +++ b/packages/web/src/hooks/use-session-socket.test.tsx @@ -12,6 +12,14 @@ import type { import type * as SwrModule from "swr"; import { isUnarchivedSessionListKey } from "@/lib/session-list"; import { useSessionSocket } from "./use-session-socket"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; + +const FULL_CAPABILITIES = { + read: true, + collaborate: true, + lifecycle: true, + sandboxAccess: true, +} satisfies SessionCapabilities; type SubscribedMessage = Extract; @@ -139,8 +147,34 @@ describe("useSessionSocket", () => { vi.restoreAllMocks(); }); + it("keeps read synchronization available without collaboration or sandbox access", async () => { + const fetchMock = vi.mocked(fetch); + const snapshot = createSnapshot(); + snapshot.session.title = "Read-only snapshot"; + + const { result } = renderHook(() => + useSessionSocket("session-1", snapshot, { + read: true, + collaborate: false, + lifecycle: false, + sandboxAccess: false, + }) + ); + + await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); + + expect(result.current.sessionState?.title).toBe("Read-only snapshot"); + expect(result.current.connected).toBe(false); + expect(fetchMock).toHaveBeenCalledWith( + "/api/sessions/session-1/ws-token", + expect.objectContaining({ method: "POST" }) + ); + }); + it("keeps sendPrompt pending until the server acknowledges the queued prompt", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -191,7 +225,9 @@ describe("useSessionSocket", () => { }); it("keeps cancelPrompt pending until the matching server acknowledgement", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); const socket = FakeWebSocket.instances[0]; act(() => { @@ -239,7 +275,9 @@ describe("useSessionSocket", () => { }); it("returns a correlated cancellation race error without treating it as success", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); const socket = FakeWebSocket.instances[0]; act(() => { @@ -266,7 +304,9 @@ describe("useSessionSocket", () => { }); it("sends correlated prompts without feature negotiation", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); const socket = FakeWebSocket.instances[0]; @@ -287,7 +327,9 @@ describe("useSessionSocket", () => { }); it("ignores unrelated acknowledgements and errors while a correlated prompt is pending", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); const socket = FakeWebSocket.instances[0]; act(() => { @@ -326,7 +368,9 @@ describe("useSessionSocket", () => { }); it("immediately rejects a correlated invalid prompt with the server message", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); const socket = FakeWebSocket.instances[0]; act(() => { @@ -354,7 +398,9 @@ describe("useSessionSocket", () => { }); it("waits for subscription and reports when a prompt cannot be sent", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -390,7 +436,9 @@ describe("useSessionSocket", () => { }); it("reuses the caller's request identity when retrying after a reconnect", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); const firstSocket = FakeWebSocket.instances[0]; act(() => { @@ -456,7 +504,9 @@ describe("useSessionSocket", () => { }); it("hydrates artifacts from the subscribed payload", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -505,7 +555,9 @@ describe("useSessionSocket", () => { }); it("hydrates screenshot metadata from subscribed artifacts", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -562,7 +614,9 @@ describe("useSessionSocket", () => { }); it("revalidates the sidebar session list on title updates", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -583,7 +637,9 @@ describe("useSessionSocket", () => { }); it("hydrates replayed assistant text before completion when storage ordering is tied", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -642,7 +698,9 @@ describe("useSessionSocket", () => { }); it("hydrates video metadata from subscribed artifacts", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -707,7 +765,9 @@ describe("useSessionSocket", () => { }); it("drops wrong-type metadata fields during narrowing", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -756,7 +816,9 @@ describe("useSessionSocket", () => { }); it("replaces stale artifacts with the subscribed snapshot", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -792,7 +854,9 @@ describe("useSessionSocket", () => { }); it("updates sessionState.branchName from session_branch without mutating the sidebar cache", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -816,7 +880,9 @@ describe("useSessionSocket", () => { }); it("routes a repo-scoped session_branch to the matching member, mirroring the scalar only for the primary", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -895,7 +961,9 @@ describe("useSessionSocket", () => { }); it("ignores an unscoped session_branch for a multi-repo session", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -952,7 +1020,9 @@ describe("useSessionSocket", () => { }); it("updates sessionState.sandboxDashboardUrl from sandbox_dashboard_url", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -979,7 +1049,9 @@ describe("useSessionSocket", () => { }); it("clears credentials on spawn and terminal statuses without dropping diagnostic links early", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -1032,7 +1104,9 @@ describe("useSessionSocket", () => { }); it("clears dashboard URL only for replacement starts, not sandbox errors", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -1084,7 +1158,9 @@ describe("useSessionSocket", () => { }); it("prepends new artifacts and replaces duplicates by id", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -1170,7 +1246,9 @@ describe("useSessionSocket", () => { }); it("applies artifact_updated in place and revalidates the session list", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -1234,7 +1312,9 @@ describe("useSessionSocket", () => { }); it("does not revalidate the session list for non-PR artifacts", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); @@ -1269,7 +1349,9 @@ describe("useSessionSocket", () => { }); it("derives prState from tracked lifecycle metadata over the legacy state key", async () => { - const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot())); + const { result } = renderHook(() => + useSessionSocket("session-1", createSnapshot(), FULL_CAPABILITIES) + ); await waitFor(() => { expect(FakeWebSocket.instances).toHaveLength(1); diff --git a/packages/web/src/hooks/use-session-socket.ts b/packages/web/src/hooks/use-session-socket.ts index 72ef39868..4eed409f3 100644 --- a/packages/web/src/hooks/use-session-socket.ts +++ b/packages/web/src/hooks/use-session-socket.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useReducer, useRef } from "react"; import { mutate } from "swr"; import { useSessionTransport } from "@/hooks/use-session-transport"; import { useSandboxAccess } from "@/hooks/use-sandbox-access"; +import type { SessionCapabilities } from "@/lib/session-capabilities"; import { ingestLiveSandboxEvent, pendingToTokenEvent, @@ -99,7 +100,8 @@ interface PendingCorrelatedRequest { */ export function useSessionSocket( sessionId: string, - initialSnapshot: SessionSnapshot + initialSnapshot: SessionSnapshot, + capabilities: SessionCapabilities ): UseSessionSocketReturn { const [state, dispatch] = useReducer( sessionSocketReducer, @@ -117,7 +119,11 @@ export function useSessionSocket( sandboxAccess, clear: clearSandboxAccess, refresh: refreshSandboxAccess, - } = useSandboxAccess(sessionId, state.sessionState?.sandboxStatus === "ready"); + } = useSandboxAccess( + sessionId, + state.sessionState?.sandboxStatus === "ready", + capabilities.sandboxAccess + ); const settleSubscriptionWaiters = useCallback((subscribed: boolean) => { for (const resolve of subscriptionWaitersRef.current) { @@ -228,10 +234,14 @@ export function useSessionSocket( dispatch({ type: "socket_closed" }); }, [settleAllCorrelatedRequests, settleSubscriptionWaiters]); - const transport = useSessionTransport(sessionId, { - onMessage: handleMessage, - onClose: handleClose, - }); + const transport = useSessionTransport( + sessionId, + { + onMessage: handleMessage, + onClose: handleClose, + }, + capabilities.read + ); const { isOpen, send, reconnect, markHealthy } = transport; useEffect(() => { diff --git a/packages/web/src/hooks/use-session-transport.test.tsx b/packages/web/src/hooks/use-session-transport.test.tsx index 6022c8ca7..a3e3a0b36 100644 --- a/packages/web/src/hooks/use-session-transport.test.tsx +++ b/packages/web/src/hooks/use-session-transport.test.tsx @@ -110,6 +110,49 @@ describe("useSessionTransport", () => { expect(result.current.isOpen()).toBe(true); }); + it("does not fetch a token or open a socket when transport is disabled", async () => { + const { result } = renderHook(() => + useSessionTransport("session-1", { onMessage, onClose }, false) + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(FakeWebSocket.instances).toHaveLength(0); + expect(result.current.connected).toBe(false); + expect(result.current.connecting).toBe(false); + + act(() => result.current.reconnect()); + expect(fetchMock).not.toHaveBeenCalled(); + expect(FakeWebSocket.instances).toHaveLength(0); + }); + + it("resets transport state across enabled to disabled to enabled", async () => { + const rendered = renderHook( + ({ enabled }) => useSessionTransport("session-1", { onMessage, onClose }, enabled), + { initialProps: { enabled: true } } + ); + await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); + act(() => FakeWebSocket.instances[0].open()); + await waitFor(() => expect(rendered.result.current.connected).toBe(true)); + + rendered.rerender({ enabled: false }); + + await waitFor(() => { + expect(rendered.result.current.connected).toBe(false); + expect(rendered.result.current.connecting).toBe(false); + }); + expect(rendered.result.current.isOpen()).toBe(false); + expect(onClose).toHaveBeenCalledTimes(1); + + rendered.rerender({ enabled: true }); + await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2)); + act(() => FakeWebSocket.instances[1].open()); + await waitFor(() => expect(rendered.result.current.connected).toBe(true)); + }); + it("forwards schema-valid messages to onMessage", async () => { const { socket } = await openSocket(); diff --git a/packages/web/src/hooks/use-session-transport.ts b/packages/web/src/hooks/use-session-transport.ts index 31b83e81d..dbe266fc2 100644 --- a/packages/web/src/hooks/use-session-transport.ts +++ b/packages/web/src/hooks/use-session-transport.ts @@ -38,6 +38,7 @@ type CloseDirective = | { action: "auth_required" } | { action: "refresh_authorization" } | { action: "session_expired" } + | { action: "authorization_revoked"; delayMs?: number } | { action: "retry"; delayMs: number } | { action: "give_up" } | { action: "none" }; @@ -97,7 +98,8 @@ export interface UseSessionTransportReturn { */ export function useSessionTransport( sessionId: string, - handlers: SessionTransportHandlers + handlers: SessionTransportHandlers, + enabled = true ): UseSessionTransportReturn { const wsRef = useRef(null); const mountedRef = useRef(true); @@ -257,6 +259,19 @@ export function useSessionTransport( wsTokenRef.current = null; return; + case "authorization_revoked": + wsTokenRef.current = null; + if (!mountedRef.current) return; + if (directive.delayMs === undefined) { + setConnectionError("Authorization could not be refreshed. Please try reconnecting."); + return; + } + reconnectAttempts.current++; + reconnectTimeoutRef.current = setTimeout(() => { + if (mountedRef.current) retry(); + }, directive.delayMs); + return; + case "retry": if (!mountedRef.current) return; reconnectAttempts.current++; @@ -346,6 +361,7 @@ export function useSessionTransport( }, []); const reconnect = useCallback(() => { + if (!enabled) return; // A connect() still awaiting its token must not open a second socket // alongside the one this call creates. invalidateInFlightConnect(); @@ -367,33 +383,51 @@ export function useSessionTransport( setAuthError(null); setConnectionError(null); connect(); - }, [connect, invalidateInFlightConnect]); + }, [connect, enabled, invalidateInFlightConnect]); const markHealthy = useCallback(() => { reconnectAttempts.current = 0; }, []); - // Connect on mount + // Track the actual component lifetime separately from capability changes. useEffect(() => { mountedRef.current = true; - connect(); - return () => { mountedRef.current = false; + }; + }, []); + + // Connect while read transport is allowed. Cleanup is also the explicit + // enabled -> disabled transition: invalidate pending work, notify the + // protocol layer, and reset all transport state before a later re-enable. + useEffect(() => { + if (enabled) connect(); + + return () => { + const discarded = wsRef.current; + const hadActiveAttempt = discarded !== null || connectingEpochRef.current !== null; invalidateInFlightConnect(); if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; } - const discarded = wsRef.current; if (discarded) { wsRef.current = null; discarded.close(); } + wsTokenRef.current = null; + reconnectAttempts.current = 0; + setConnected(false); + setConnecting(false); + setAuthError(null); + setConnectionError(null); + if (hadActiveAttempt) handlersRef.current.onClose?.(); }; - }, [connect, invalidateInFlightConnect]); + }, [connect, enabled, invalidateInFlightConnect]); // Ping periodically to keep connection alive. useEffect(() => { + if (!enabled) return; const pingInterval = setInterval(() => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: "ping" })); @@ -401,7 +435,7 @@ export function useSessionTransport( }, PING_INTERVAL_MS); return () => clearInterval(pingInterval); - }, []); + }, [enabled]); return { connected, diff --git a/packages/web/src/lib/automation-authorization.test.ts b/packages/web/src/lib/automation-authorization.test.ts new file mode 100644 index 000000000..6b4ad58db --- /dev/null +++ b/packages/web/src/lib/automation-authorization.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import type { EffectiveAuthorization, PermissionId } from "@open-inspect/shared/rbac"; +import { canAccessAutomation } from "./automation-authorization"; + +const CURRENT_USER_ID = "11111111111111111111111111111111"; +const OTHER_USER_ID = "22222222222222222222222222222222"; + +function authorization(permissions: PermissionId[]): EffectiveAuthorization { + return { + userId: CURRENT_USER_ID, + suspendedAt: null, + role: { id: "role-1", key: null, name: "Test" }, + permissions, + }; +} + +describe("canAccessAutomation", () => { + it("allows any scope regardless of ownership", () => { + expect( + canAccessAutomation("automations.manage", authorization(["automations.manage.any"]), { + userId: OTHER_USER_ID, + }) + ).toBe(true); + }); + + it("allows own scope only for the canonical owner", () => { + const auth = authorization(["automations.trigger.own"]); + expect(canAccessAutomation("automations.trigger", auth, { userId: CURRENT_USER_ID })).toBe( + true + ); + expect(canAccessAutomation("automations.trigger", auth, { userId: OTHER_USER_ID })).toBe(false); + expect(canAccessAutomation("automations.trigger", auth, { userId: null })).toBe(false); + }); + + it("denies missing authorization and unrelated capabilities", () => { + expect(canAccessAutomation("automations.manage", null, { userId: CURRENT_USER_ID })).toBe( + false + ); + expect( + canAccessAutomation("automations.manage", authorization(["automations.trigger.any"]), { + userId: CURRENT_USER_ID, + }) + ).toBe(false); + }); +}); diff --git a/packages/web/src/lib/automation-authorization.ts b/packages/web/src/lib/automation-authorization.ts new file mode 100644 index 000000000..87a0704ba --- /dev/null +++ b/packages/web/src/lib/automation-authorization.ts @@ -0,0 +1,20 @@ +import { + hasScopedPermission, + type EffectiveAuthorization, + type ScopedPermissionStem, +} from "@open-inspect/shared/rbac"; +import type { Automation } from "@open-inspect/shared/types/automations"; + +/** Checks an automation capability against its canonical owner identity. */ +export function canAccessAutomation( + stem: ScopedPermissionStem, + authorization: EffectiveAuthorization | null, + automation: Pick +): boolean { + if (!authorization) return false; + return hasScopedPermission( + stem, + authorization.permissions, + automation.userId === authorization.userId + ); +} diff --git a/packages/web/src/lib/session-capabilities.ts b/packages/web/src/lib/session-capabilities.ts new file mode 100644 index 000000000..b7abe004e --- /dev/null +++ b/packages/web/src/lib/session-capabilities.ts @@ -0,0 +1,20 @@ +import type { PermissionId } from "@open-inspect/shared/rbac"; + +/** Required session capability model shared by the page and every privileged child control. */ +export interface SessionCapabilities { + read: boolean; + collaborate: boolean; + lifecycle: boolean; + sandboxAccess: boolean; +} + +export function resolveSessionCapabilities( + hasPermission: (permission: PermissionId) => boolean +): SessionCapabilities { + return { + read: hasPermission("sessions.read"), + collaborate: hasPermission("sessions.collaborate"), + lifecycle: hasPermission("sessions.lifecycle"), + sandboxAccess: hasPermission("sessions.sandbox_access"), + }; +}