Configure Model Context Protocol servers that are available to agent sessions.
@@ -635,7 +643,8 @@ export function McpServersSettings() {
startEdit(server)}
+ onClick={() => canManage && startEdit(server)}
+ disabled={!canManage}
>
-
+ );
+}
diff --git a/packages/web/src/hooks/use-automations.test.tsx b/packages/web/src/hooks/use-automations.test.tsx
index 5d1068117..c944029e6 100644
--- a/packages/web/src/hooks/use-automations.test.tsx
+++ b/packages/web/src/hooks/use-automations.test.tsx
@@ -25,6 +25,7 @@ function automation(id: string, name: string): AutomationListItem {
nextRunAt: null,
consecutiveFailures: 0,
createdBy: "user-1",
+ userId: "11111111111111111111111111111111",
createdAt: 1,
updatedAt: 1,
deletedAt: null,
diff --git a/packages/web/src/hooks/use-current-user-authorization.test.tsx b/packages/web/src/hooks/use-current-user-authorization.test.tsx
new file mode 100644
index 000000000..a8c087543
--- /dev/null
+++ b/packages/web/src/hooks/use-current-user-authorization.test.tsx
@@ -0,0 +1,60 @@
+// @vitest-environment jsdom
+
+import { renderHook, waitFor } from "@testing-library/react";
+import { SWRConfig } from "swr";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ReactNode } from "react";
+import { useAuthSession } from "@/lib/auth-session";
+import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useCurrentUserAuthorization } from "./use-current-user-authorization";
+
+vi.mock("@/lib/auth-session", () => ({ useAuthSession: vi.fn() }));
+vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() }));
+
+const authorizations = {
+ owner: {
+ userId: "11111111111111111111111111111111",
+ suspendedAt: null,
+ role: { id: "role_builtin_owner", key: "owner" as const, name: "Owner" },
+ permissions: ["workspace.transfer_ownership" as const],
+ },
+ member: {
+ userId: "22222222222222222222222222222222",
+ suspendedAt: null,
+ role: { id: "role_builtin_member", key: "member" as const, name: "Member" },
+ permissions: ["repositories.read" as const],
+ },
+};
+
+describe("useCurrentUserAuthorization", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("does not reuse cached authorization after the authenticated user changes", async () => {
+ let currentUser: keyof typeof authorizations = "owner";
+ vi.mocked(useAuthSession).mockImplementation(
+ () =>
+ ({
+ status: "authenticated",
+ data: { user: { id: authorizations[currentUser].userId } },
+ }) as ReturnType
+ );
+ vi.mocked(browserApiFetch).mockImplementation(async () =>
+ Response.json(authorizations[currentUser])
+ );
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ new Map(), dedupingInterval: 0 }}>{children}
+ );
+ const { result, rerender } = renderHook(useCurrentUserAuthorization, { wrapper });
+ await waitFor(() => expect(result.current.authorization?.role.key).toBe("owner"));
+ const ownerHasPermission = result.current.hasPermission;
+ rerender();
+ expect(result.current.hasPermission).toBe(ownerHasPermission);
+
+ currentUser = "member";
+ rerender();
+
+ await waitFor(() => expect(result.current.authorization?.role.key).toBe("member"));
+ expect(result.current.hasPermission).not.toBe(ownerHasPermission);
+ expect(result.current.hasPermission("workspace.transfer_ownership")).toBe(false);
+ });
+});
diff --git a/packages/web/src/hooks/use-current-user-authorization.ts b/packages/web/src/hooks/use-current-user-authorization.ts
new file mode 100644
index 000000000..8eb4204fe
--- /dev/null
+++ b/packages/web/src/hooks/use-current-user-authorization.ts
@@ -0,0 +1,53 @@
+"use client";
+
+import useSWR from "swr";
+import {
+ effectiveAuthorizationSchema,
+ type EffectiveAuthorization,
+ type PermissionId,
+} from "@open-inspect/shared/rbac";
+import { useAuthSession } from "@/lib/auth-session";
+import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useCallback } from "react";
+
+/** Endpoint key for the signed-in user's effective workspace authorization. */
+export const CURRENT_USER_AUTHORIZATION_KEY = "/api/me/authorization" as const;
+
+/** Returns the user-scoped cache key for effective workspace authorization. */
+export function currentUserAuthorizationKey(userId: string) {
+ return [CURRENT_USER_AUTHORIZATION_KEY, userId] as const;
+}
+
+async function fetchAuthorization(): Promise {
+ const response = await browserApiFetch(CURRENT_USER_AUTHORIZATION_KEY);
+ if (!response.ok) throw new Error(`Authorization request failed (${response.status})`);
+ return effectiveAuthorizationSchema.parse(await response.json());
+}
+
+/**
+ * Provides the signed-in user's effective permissions, denying permission checks until they load.
+ */
+export function useCurrentUserAuthorization(): {
+ authorization: EffectiveAuthorization | null;
+ loading: boolean;
+ error: unknown;
+ hasPermission: (permission: PermissionId) => boolean;
+} {
+ const { data: session, status } = useAuthSession();
+ const userId = session?.user?.id;
+ const { data, isLoading, error } = useSWR(
+ status === "authenticated" && userId ? currentUserAuthorizationKey(userId) : null,
+ fetchAuthorization
+ );
+ const hasPermission = useCallback(
+ (permission: PermissionId) => data?.permissions.includes(permission) ?? false,
+ [data?.permissions]
+ );
+
+ return {
+ authorization: data ?? null,
+ loading: status === "authenticated" && isLoading,
+ error,
+ hasPermission,
+ };
+}
diff --git a/packages/web/src/hooks/use-provider-accounts.test.tsx b/packages/web/src/hooks/use-provider-accounts.test.tsx
index 66e9b2c17..e31b3dbba 100644
--- a/packages/web/src/hooks/use-provider-accounts.test.tsx
+++ b/packages/web/src/hooks/use-provider-accounts.test.tsx
@@ -18,12 +18,21 @@ import {
useProviderAccounts,
} from "./use-provider-accounts";
-vi.mock("@/lib/auth-session", () => ({
- useAuthSession: () => ({ data: { user: { id: "user-1" } }, status: "authenticated" }),
+const permissions = vi.hoisted(() => new Set());
+
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) => permissions.has(permission),
+ }),
}));
vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() }));
+beforeEach(() => {
+ permissions.clear();
+ permissions.add("provider_accounts.read");
+});
+
function wrapper({ children }: { children: ReactNode }) {
return (
new Map(), dedupingInterval: 0 }}>{children}
@@ -91,6 +100,34 @@ describe("useLegacyProviderCredentials", () => {
describe("useProviderAccounts", () => {
beforeEach(() => vi.clearAllMocks());
+ it("does not request provider resources without read permission", () => {
+ permissions.clear();
+
+ const { result } = renderHook(
+ () => ({ accounts: useProviderAccounts(), legacy: useLegacyProviderCredentials() }),
+ { wrapper }
+ );
+
+ expect(browserApiFetch).not.toHaveBeenCalled();
+ expect(result.current.accounts).toMatchObject({ accounts: [], defaults: [], loading: false });
+ expect(result.current.legacy).toMatchObject({ legacyKeys: [], loading: false });
+ });
+
+ it("clears provider resources when read permission is revoked", async () => {
+ vi.mocked(browserApiFetch)
+ .mockResolvedValueOnce(Response.json({ accounts: [account] }))
+ .mockResolvedValueOnce(Response.json({ defaults: [] }));
+
+ const { result, rerender } = renderHook(() => useProviderAccounts(), { wrapper });
+ await waitFor(() => expect(result.current.accounts).toEqual([account]));
+
+ permissions.clear();
+ rerender();
+
+ expect(result.current).toMatchObject({ accounts: [], defaults: [], loading: false });
+ expect(browserApiFetch).toHaveBeenCalledTimes(2);
+ });
+
it("uses the shared static provider catalog without fetching it", async () => {
vi.mocked(browserApiFetch)
.mockResolvedValueOnce(Response.json({ accounts: [] }))
diff --git a/packages/web/src/hooks/use-provider-accounts.ts b/packages/web/src/hooks/use-provider-accounts.ts
index 5c2a2348c..b281221f9 100644
--- a/packages/web/src/hooks/use-provider-accounts.ts
+++ b/packages/web/src/hooks/use-provider-accounts.ts
@@ -1,6 +1,6 @@
import useSWR from "swr";
import { z, type ZodType } from "zod";
-import { useAuthSession } from "@/lib/auth-session";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch";
import {
modelProviderAccountDefaultsResponseSchema,
@@ -89,11 +89,12 @@ async function requestProviderResourceWithoutContent(
}
export function useProviderAccounts() {
- const { data: session } = useAuthSession();
- const accounts = useSWR(session ? ACCOUNTS_KEY : null, async (path) => {
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canRead = hasPermission("provider_accounts.read");
+ const accounts = useSWR(canRead ? ACCOUNTS_KEY : null, async (path) => {
return (await requestProviderResource(path, modelProviderAccountsResponseSchema)).accounts;
});
- const defaults = useSWR(session ? DEFAULTS_KEY : null, async (path) => {
+ const defaults = useSWR(canRead ? DEFAULTS_KEY : null, async (path) => {
return (await requestProviderResource(path, modelProviderAccountDefaultsResponseSchema))
.defaults;
});
@@ -112,9 +113,10 @@ export function useProviderAccounts() {
}
export function useLegacyProviderCredentials() {
- const { data: session } = useAuthSession();
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canRead = hasPermission("provider_accounts.read");
const result = useSWR(
- session ? LEGACY_CREDENTIALS_KEY : null,
+ canRead ? LEGACY_CREDENTIALS_KEY : null,
async (path: BrowserApiPath) => {
return requestProviderResource(path, legacyProviderCredentialsResponseSchema);
}
diff --git a/packages/web/src/hooks/use-repos.test.tsx b/packages/web/src/hooks/use-repos.test.tsx
new file mode 100644
index 000000000..2aaeefa80
--- /dev/null
+++ b/packages/web/src/hooks/use-repos.test.tsx
@@ -0,0 +1,31 @@
+// @vitest-environment jsdom
+
+import { renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useRepos } from "./use-repos";
+
+const mocks = vi.hoisted(() => ({ useSWR: vi.fn() }));
+
+vi.mock("swr", () => ({ default: mocks.useSWR }));
+vi.mock("@/lib/auth-session", () => ({
+ useAuthSession: () => ({ data: { user: {} }, status: "authenticated" }),
+}));
+
+describe("useRepos", () => {
+ beforeEach(() => {
+ mocks.useSWR.mockReset();
+ mocks.useSWR.mockReturnValue({ data: undefined, isLoading: false, error: undefined });
+ });
+
+ it("does not request repositories when the caller is unauthorized", () => {
+ renderHook(() => useRepos(false));
+
+ expect(mocks.useSWR).toHaveBeenCalledWith(null);
+ });
+
+ it("requests repositories when enabled", () => {
+ renderHook(() => useRepos());
+
+ expect(mocks.useSWR).toHaveBeenCalledWith("/api/repos");
+ });
+});
diff --git a/packages/web/src/hooks/use-repos.ts b/packages/web/src/hooks/use-repos.ts
index f4f3799f6..56f3a3a7b 100644
--- a/packages/web/src/hooks/use-repos.ts
+++ b/packages/web/src/hooks/use-repos.ts
@@ -15,16 +15,21 @@ interface ReposResponse {
repos: Repo[];
}
-export function useRepos() {
+/**
+ * Loads repositories for an authenticated user when enabled, allowing callers to suppress unauthorized requests.
+ */
+export function useRepos(enabled = true) {
const { data: session, status } = useAuthSession();
- const { data, isLoading, error } = useSWR(session ? "/api/repos" : null);
+ const { data, isLoading, error } = useSWR(
+ enabled && session ? "/api/repos" : null
+ );
return {
repos: data?.repos ?? [],
// The fetch is gated on the auth session, so the list is still loading
// while the session itself resolves — don't report an authoritative [].
- loading: status === "loading" || isLoading,
+ loading: enabled && (status === "loading" || isLoading),
error,
};
}
diff --git a/packages/web/src/hooks/use-workspace-administration.test.tsx b/packages/web/src/hooks/use-workspace-administration.test.tsx
new file mode 100644
index 000000000..44046d1dd
--- /dev/null
+++ b/packages/web/src/hooks/use-workspace-administration.test.tsx
@@ -0,0 +1,55 @@
+// @vitest-environment jsdom
+
+import { act, renderHook } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { SWRConfig } from "swr";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useAuthSession } from "@/lib/auth-session";
+import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useWorkspaceAdministration } from "./use-workspace-administration";
+
+vi.mock("@/lib/auth-session", () => ({ useAuthSession: vi.fn() }));
+vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() }));
+
+const wrapper = ({ children }: { children: ReactNode }) => (
+ new Map(), dedupingInterval: 0 }}>{children}
+);
+
+const member = {
+ userId: "11111111111111111111111111111111",
+ displayName: "Ada",
+ email: "ada@example.com",
+ avatarUrl: null,
+ suspendedAt: null,
+ role: { id: "role_builtin_member", key: "member" as const, name: "Member" },
+ createdAt: 1,
+};
+
+describe("useWorkspaceAdministration", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(useAuthSession).mockReturnValue({ data: null, status: "unauthenticated" });
+ vi.mocked(browserApiFetch).mockResolvedValue(new Response(null, { status: 204 }));
+ });
+
+ it("sends the simplified role and suspension mutation contracts", async () => {
+ const { result } = renderHook(
+ () => useWorkspaceAdministration({ readMembers: false, readRoles: false }),
+ { wrapper }
+ );
+
+ await act(() => result.current.updateMember(member, { kind: "role", roleId: "role_release" }));
+ await act(() => result.current.updateMember(member, { kind: "status", suspended: true }));
+
+ expect(browserApiFetch).toHaveBeenNthCalledWith(
+ 1,
+ `/api/members/${member.userId}/role`,
+ expect.objectContaining({ method: "PUT", body: JSON.stringify({ roleId: "role_release" }) })
+ );
+ expect(browserApiFetch).toHaveBeenNthCalledWith(
+ 2,
+ `/api/members/${member.userId}/status`,
+ expect.objectContaining({ method: "PUT", body: JSON.stringify({ suspended: true }) })
+ );
+ });
+});
diff --git a/packages/web/src/hooks/use-workspace-administration.ts b/packages/web/src/hooks/use-workspace-administration.ts
new file mode 100644
index 000000000..6fc155cbd
--- /dev/null
+++ b/packages/web/src/hooks/use-workspace-administration.ts
@@ -0,0 +1,67 @@
+"use client";
+
+import useSWR, { useSWRConfig } from "swr";
+import {
+ roleListResponseSchema,
+ workspaceMemberListResponseSchema,
+ type RoleSummary,
+ type WorkspaceMember,
+} from "@open-inspect/shared/rbac";
+import { browserApiFetch } from "@/lib/browser-api-fetch";
+import { useAuthSession } from "@/lib/auth-session";
+import { currentUserAuthorizationKey } from "./use-current-user-authorization";
+
+async function fetchMembers(): Promise {
+ const response = await browserApiFetch("/api/members");
+ if (!response.ok) throw new Error(`Members request failed (${response.status})`);
+ return workspaceMemberListResponseSchema.parse(await response.json());
+}
+
+async function fetchRoles(): Promise {
+ const response = await browserApiFetch("/api/roles");
+ if (!response.ok) throw new Error(`Roles request failed (${response.status})`);
+ return roleListResponseSchema.parse(await response.json());
+}
+
+/**
+ * Provides the workspace members and roles the current user may read, plus authorized member updates.
+ */
+export function useWorkspaceAdministration(input: { readMembers: boolean; readRoles: boolean }) {
+ const { mutate } = useSWRConfig();
+ const { data: session } = useAuthSession();
+ const members = useSWR(input.readMembers ? "/api/members" : null, fetchMembers);
+ const roles = useSWR(input.readRoles ? "/api/roles" : null, fetchRoles);
+
+ async function updateMember(
+ user: WorkspaceMember,
+ action: { kind: "role"; roleId: string } | { kind: "status"; suspended: boolean }
+ ): Promise {
+ const path =
+ action.kind === "role"
+ ? (`/api/members/${encodeURIComponent(user.userId)}/role` as const)
+ : (`/api/members/${encodeURIComponent(user.userId)}/status` as const);
+ const body =
+ action.kind === "role" ? { roleId: action.roleId } : { suspended: action.suspended };
+ const response = await browserApiFetch(path, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) throw new Error(`Member update failed (${response.status})`);
+ await Promise.all([
+ members.mutate(),
+ roles.mutate(),
+ session?.user?.id
+ ? mutate(currentUserAuthorizationKey(session.user.id), undefined, { revalidate: true })
+ : Promise.resolve(undefined),
+ ]);
+ }
+
+ return {
+ members: members.data ?? [],
+ roles: roles.data ?? [],
+ loading: (input.readMembers && members.isLoading) || (input.readRoles && roles.isLoading),
+ error: members.error ?? roles.error,
+ updateMember,
+ };
+}
From 3f39e3ac9476cab799aa15f264f100020f0f9d4a Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Sun, 30 Aug 2026 21:56:45 -0700
Subject: [PATCH 06/11] feat: gate session and automation UI by permission
---
README.md | 8 +-
docs/AUTH.md | 206 +++++
docs/GETTING_STARTED.md | 62 +-
.../automations/[id]/edit/page.test.tsx | 92 ++
.../(sidebar)/automations/[id]/edit/page.tsx | 18 +-
.../(sidebar)/automations/[id]/page.test.tsx | 107 +++
.../(app)/(sidebar)/automations/[id]/page.tsx | 106 ++-
.../(sidebar)/automations/new/page.test.tsx | 21 +-
.../(app)/(sidebar)/automations/new/page.tsx | 11 +-
.../(app)/(sidebar)/automations/page.test.tsx | 29 +-
.../app/(app)/(sidebar)/automations/page.tsx | 27 +-
.../automations/templates/page.test.tsx | 54 ++
.../(sidebar)/automations/templates/page.tsx | 12 +
.../web/src/app/(app)/(sidebar)/page.test.tsx | 20 +
packages/web/src/app/(app)/(sidebar)/page.tsx | 16 +-
.../app/(app)/(sidebar)/session/[id]/page.tsx | 109 ++-
.../web/src/components/action-bar.test.tsx | 14 +
packages/web/src/components/action-bar.tsx | 35 +-
.../automations/automations-list.test.tsx | 81 +-
.../automations/automations-list.tsx | 229 ++---
.../web/src/components/diff-retry-notice.tsx | 26 +-
.../src/components/mobile-session-actions.tsx | 31 +-
.../components/queued-prompt-stack.test.tsx | 13 +
.../src/components/queued-prompt-stack.tsx | 24 +-
.../web/src/components/session-actions.ts | 1 +
.../src/components/session-changes-panel.tsx | 9 +-
.../components/session-details-overlay.tsx | 6 +
.../src/components/session-header.test.tsx | 30 +
.../web/src/components/session-header.tsx | 18 +-
.../src/components/session-list-item.test.tsx | 95 ++
.../web/src/components/session-list-item.tsx | 115 +--
.../components/session-prompt-composer.tsx | 2 +
.../components/session-right-sidebar.test.tsx | 39 +
.../src/components/session-right-sidebar.tsx | 38 +-
.../src/components/session-sidebar.test.tsx | 18 +-
.../web/src/components/session-sidebar.tsx | 4 +-
.../settings/data-controls-settings.test.tsx | 23 +
.../settings/data-controls-settings.tsx | 40 +-
.../src/components/sidebar-layout.test.tsx | 22 +
.../web/src/components/sidebar-layout.tsx | 9 +-
.../components/sidebar/metadata-section.tsx | 4 +-
.../src/hooks/use-global-shortcuts.test.tsx | 37 +-
.../web/src/hooks/use-global-shortcuts.ts | 6 +-
packages/web/src/hooks/use-sandbox-access.ts | 9 +-
.../web/src/hooks/use-session-socket.test.tsx | 22 +
packages/web/src/hooks/use-session-socket.ts | 24 +-
.../src/hooks/use-session-transport.test.tsx | 39 +
.../web/src/hooks/use-session-transport.ts | 33 +-
.../src/lib/automation-authorization.test.ts | 45 +
.../web/src/lib/automation-authorization.ts | 17 +
.../docs/internal/2026-08-28-rbac-design.md | 815 ++++++++++++++++++
.../docs/internal/2026-08-28-rbac-research.md | 386 +++++++++
.../2026-08-30-session-access-research.md | 407 +++++++++
...space-wide-session-authorization-design.md | 193 +++++
54 files changed, 3487 insertions(+), 370 deletions(-)
create mode 100644 docs/AUTH.md
create mode 100644 packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx
create mode 100644 packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx
create mode 100644 packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx
create mode 100644 packages/web/src/components/session-list-item.test.tsx
create mode 100644 packages/web/src/lib/automation-authorization.test.ts
create mode 100644 packages/web/src/lib/automation-authorization.ts
create mode 100644 public/docs/internal/2026-08-28-rbac-design.md
create mode 100644 public/docs/internal/2026-08-28-rbac-research.md
create mode 100644 public/docs/internal/2026-08-30-session-access-research.md
create mode 100644 public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md
diff --git a/README.md b/README.md
index 304c79a66..58c915e48 100644
--- a/README.md
+++ b/README.md
@@ -29,8 +29,9 @@ The system uses a shared GitHub App installation for git operations (clone, fetc
control plane mints short-lived installation tokens server-side and brokers them to sandboxes
through the git credential helper on demand. This means:
-- **All users share the same GitHub App credentials** - The GitHub App must be installed on your
- organization's repositories, and any user of the system can access any repo the App has access to
+- **Authorized users share the same GitHub App credentials** - The GitHub App must be installed on
+ your organization's repositories, and active users whose role permits repository use can access
+ any repo the App has access to
- **No per-user repository access validation** - The system does not verify that a user has
permission to access a specific repository before creating a session
- **GitHub users' OAuth tokens are used for PR creation** - For GitHub logins, PRs are created using
@@ -70,6 +71,9 @@ built for internal use where all employees are trusted and have access to compan
4. **Use GitHub's repository selection** - When installing the App, select specific repositories
rather than "All repositories"
+See [Authentication and Authorization](docs/AUTH.md) for workspace roles, session access, automation
+ownership, bots, and member suspension.
+
## Architecture
```
diff --git a/docs/AUTH.md b/docs/AUTH.md
new file mode 100644
index 000000000..e8d9f4fc8
--- /dev/null
+++ b/docs/AUTH.md
@@ -0,0 +1,206 @@
+# Authentication and Authorization
+
+Open-Inspect uses authentication to establish who you are and workspace authorization to decide what
+you can do. This guide explains the behavior users and workspace administrators will see.
+
+> **Important:** Open-Inspect is designed for a single trusted organization. A deployment is one
+> workspace, and the source-control App installation defines the repositories available to that
+> workspace. Roles control which Open-Inspect features a person can use; they are not per-repository
+> access lists.
+
+---
+
+## Signing In
+
+A deployment can offer GitHub sign-in, Google sign-in, or both. The sign-in page shows only the
+providers configured by the deployment operator.
+
+Signing in has two stages:
+
+1. Your identity provider verifies your identity and email address.
+2. The deployment's admission rules determine whether you may join the workspace.
+
+Depending on the deployment configuration, admission can be limited by:
+
+- GitHub username
+- Verified email address
+- Verified email domain
+- Active membership in an allowed GitHub organization
+
+These rules are checked when you sign in. Removing someone from an allowlist or GitHub organization
+does not end an existing browser session; an Administrator or Owner can suspend the member when
+access must be revoked immediately.
+
+Authentication does not make someone an Owner or Administrator. Every admitted user has exactly one
+workspace role, and new users receive the Member role by default.
+
+## Workspace Roles
+
+Open-Inspect includes four built-in roles.
+
+| Capability | Owner | Administrator | Member | Viewer |
+| ------------------------------------------------- | :---: | :-----------: | :----: | :----: |
+| View repositories and environments | Yes | Yes | Yes | Yes |
+| Use repositories and environments in sessions | Yes | Yes | Yes | No |
+| Manage shared settings, integrations, and secrets | Yes | Yes | No | No |
+| Create sessions | Yes | Yes | Yes | No |
+| View every session | Yes | Yes | Yes | Yes |
+| Collaborate in and manage sessions | Yes | Yes | Yes | No |
+| View automations | Yes | Yes | Yes | Yes |
+| Create automations | Yes | Yes | Yes | No |
+| Manage and trigger own automations | Yes | Yes | Yes | No |
+| Manage and trigger any automation | Yes | Yes | No | No |
+| View and manage workspace members | Yes | Yes | No | No |
+| Transfer workspace ownership | Yes | No | No | No |
+| View analytics | Yes | Yes | Yes | Yes |
+| View provider accounts | Yes | Yes | Yes | No |
+| View image-build history | Yes | Yes | Yes | Yes |
+| Manage personal skill profiles | Yes | Yes | Yes | No |
+
+### Owner
+
+Owners have full access to the workspace. Only Owners can grant or remove the Owner role or suspend
+and restore another Owner. Open-Inspect also prevents the final active Owner from being suspended or
+demoted, so the workspace cannot accidentally lose all ownership.
+
+### Administrator
+
+Administrators can operate the workspace day to day. They can manage members, sessions, automations,
+repositories, environments, provider accounts, integrations, and secrets. They cannot transfer
+ownership, change who holds the Owner role, or suspend and restore an Owner.
+
+### Member
+
+Members can create and use sessions, collaborate in existing sessions, use shared repositories and
+environments, and create automations. They can manage and manually trigger automations they own but
+cannot modify another person's automation or administer shared configuration. They can view
+workspace analytics.
+
+### Viewer
+
+Viewers have read-only access to shared workspace resources. They can inspect sessions, automations,
+analytics, repositories, environments, skills, and MCP servers. They cannot create or prompt
+sessions, access sandboxes, manage personal skill profiles, trigger automations, or change shared
+configuration.
+
+## How Session Access Works
+
+Sessions are workspace resources rather than private resources owned by their creator.
+
+- Anyone with session read access can view every session in the workspace.
+- Anyone with collaboration access can prompt and contribute to every session.
+- Anyone with lifecycle access can stop, retry, archive, unarchive, and otherwise manage every
+ session.
+- Anyone with sandbox access can use supported sandbox tools for every session.
+- Anyone with delete access can delete every session.
+
+The creator shown on a session records attribution; it is not an access list. Likewise, participant
+labels identify who contributed to a session but do not grant or remove workspace permissions. The
+**Mine** filter is a convenience for finding sessions you created, not a security boundary.
+
+Creating a session also requires permission to use its selected repository or environment. A role
+may therefore be able to view an existing session without being allowed to create a new one.
+
+New HTTP requests reflect role changes and suspension immediately. Live browser connections to a
+session are rechecked at least every five minutes, so a connection may remain open for up to five
+minutes after access changes. Recreating the session is not required.
+
+## How Automation Access Works
+
+Automation definitions and run history are visible workspace-wide to roles with automation read
+access. Creating, changing, and manually triggering automations use ownership rules.
+
+- Members can manage and manually trigger automations they own.
+- Administrators and Owners can manage and manually trigger any automation.
+- Viewers can inspect automations but cannot create, change, or run them.
+
+Automation ownership follows the signed-in account that created it, not a display name or external
+provider username.
+
+### Scheduled and Event Runs
+
+Scheduled and event-driven runs execute under the automation owner's authority. At run time, the
+owner must still be active and allowed to create sessions and use every selected repository or
+environment. If those permissions have been removed, the run does not start.
+
+### Manual Runs
+
+A manual run executes under the authority of the person who clicked **Run**, even when an
+Administrator or Owner triggers someone else's automation. The requester must be allowed both to
+trigger that automation and to create the resulting session with its selected resources. Their
+identity and linked source-control credentials are used for that run.
+
+See [Automations](AUTOMATIONS.md) for trigger setup and run behavior.
+
+## Bots and Integrations
+
+Slack, GitHub, and Linear integrations act on behalf of a workspace user when they handle a user
+request. Their effective access is limited by both:
+
+- The acting user's current role
+- The integration's fixed set of allowed operations
+
+This means an integration cannot bypass a suspended user or perform workspace administration simply
+because the acting user is an Owner. Calls that do not identify an acting user are denied unless a
+specific integration route explicitly permits that operation.
+
+Some integrations also apply their own ingress rules. For example, the GitHub integration may
+require an allowed trigger user or sufficient repository collaborator access before it sends a
+request to Open-Inspect.
+
+## Suspension
+
+Suspending a member disables their workspace access without deleting their account or historical
+attribution.
+
+After suspension:
+
+- New browser and bot operations are denied.
+- Existing browser sign-in sessions are invalidated.
+- Live browser session connections close within five minutes.
+- Scheduled and event-driven automations owned by the member no longer pass run authorization.
+- Existing session history and authorship remain intact.
+
+Suspension does not automatically stop a sandbox that is already executing. An Administrator or
+Owner can manage that session separately.
+
+## Repository and Credential Boundaries
+
+Open-Inspect uses a shared source-control App installation for clone, fetch, and push operations.
+The App should be installed only on repositories intended for the workspace.
+
+A user's role determines whether they may read or use workspace repositories, but Open-Inspect does
+not compare that role with the user's personal GitHub access for each repository. Linked GitHub
+credentials can be used for actions such as attributed pull-request creation; when no suitable user
+credential is available, supported operations may use the shared App identity.
+
+Secrets and provider credentials are not made visible through role-based read access. Administrative
+permissions control who can configure them, and saved secret values are not returned to the browser.
+See [Secrets Management](SECRETS.md) for details.
+
+## Workspace Administration
+
+Owners and Administrators can manage members from **Settings > Workspace access**. Depending on
+their own role, they can:
+
+- Review workspace members and assigned roles
+- Change a member's role
+- Suspend or restore a member
+
+Only an Owner can assign or remove the Owner role or suspend and restore another Owner. The final
+active Owner cannot be suspended or demoted.
+
+### Initial Owner Setup
+
+The first person who signs in receives the default Member role and is not promoted to Owner
+automatically. On a new deployment, the intended Owner must sign in once, after which a deployment
+operator runs the Owner bootstrap command using that person's Open-Inspect user ID. See
+[Getting Started](GETTING_STARTED.md#step-7a-bootstrap-the-workspace-owner) for the deployment
+steps.
+
+## Related Guides
+
+- [Getting Started](GETTING_STARTED.md)
+- [Automations](AUTOMATIONS.md)
+- [Secrets Management](SECRETS.md)
+- [How Open-Inspect Works](HOW_IT_WORKS.md)
diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md
index d9c2402dc..9600c52d6 100644
--- a/docs/GETTING_STARTED.md
+++ b/docs/GETTING_STARTED.md
@@ -302,10 +302,11 @@ GitHub OAuth sign-in, but its client pair is optional when Google is the only si
> **Keep "User-to-server token expiration" active** (GitHub App → **Optional Features**; it is
> the default for newly created Apps, but activate it if yours predates that default). Expiring
> user tokens are what make GitHub return a **refresh token** at sign-in, and Open-Inspect stores
- > that per-user credential so sessions clone, commit, and push **as the signed-in user**. With
- > expiration deactivated — or on an **OAuth App**, which never issues a refresh token — no
- > per-user credential is captured and sessions fall back to the shared GitHub App **bot**
- > identity for repository access.
+ > that per-user credential for attributed GitHub operations such as pull-request creation. Clone,
+ > fetch, and push authentication still use the shared GitHub App installation. With expiration
+ > deactivated — or on an **OAuth App**, which never issues a refresh token — no per-user
+ > credential is captured, so supported attributed operations fall back to the shared GitHub App
+ > **bot** identity.
5. Set **Repository permissions**:
- Actions: **Read-only** _(required for GitHub workflow-run automations)_
@@ -651,10 +652,9 @@ configurations because they authorize repository operations; they do not enable
### Enable Google Login (Optional)
-Google login lets non-developer users (PMs, support agents) sign in without a GitHub account. They
-get the same flat access as everyone else; git operations still use the shared GitHub App, and their
-PRs fall back to the App bot (no personal GitHub attribution unless the same verified email is also
-a linked GitHub identity).
+Google login lets non-developer users (PMs, support agents) sign in without a GitHub account. Git
+operations still use the shared GitHub App, and their PRs fall back to the App bot (no personal
+GitHub attribution unless the same verified email is also a linked GitHub identity).
1. In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), create an
**OAuth client ID** of type **Web application**.
@@ -726,6 +726,52 @@ Terraform will update the workers with the required bindings.
---
+## Step 7a: Bootstrap the Workspace Owner
+
+Owner assignment is an explicit operator action. After both deployment phases complete:
+
+1. Have the intended Owner sign in to the deployed web application once. This creates their
+ canonical user and default role assignment.
+2. While signed in, open `/api/auth/get-session` on the web application origin and record the
+ 32-character lowercase hexadecimal `user.id`. The bootstrap command accepts this canonical ID,
+ never an email address.
+3. Obtain the D1 database name with `terraform output -raw d1_database_name` from
+ `terraform/environments/production`.
+4. From the repository root, run the remote dry run (the default):
+
+```bash
+npm run rbac:bootstrap-owner -- \
+ --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \
+ --user ""
+```
+
+5. Confirm the preflight result is `ready` (or `no-op` when the target is already the current
+ unsuspended Owner), then execute the same command with `--execute`:
+
+```bash
+npm run rbac:bootstrap-owner -- \
+ --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \
+ --user "" \
+ --execute
+```
+
+The command uses Wrangler credentials (`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, or
+`wrangler login`) and targets remote D1. It refuses a suspended/missing user, a missing or ambiguous
+assignment, or another unsuspended Owner. There is no force option. Execution is one atomic Wrangler
+SQL file: it writes one redacted `workspace.owner_bootstrapped` service audit event and replaces the
+target's assignment. A no-op writes nothing.
+
+6. Verify the control-plane health response contains `"rbac":{"ownerAssignment":"present"}`:
+
+```bash
+curl "$(terraform -chdir=terraform/environments/production output -raw control_plane_url)/health"
+```
+
+This health value reports current state: `present` means at least one Owner assignment belongs to an
+unsuspended user.
+
+---
+
## Step 7b: Complete Slack Setup (If Using Slack)
Now that the Slack bot worker is deployed, configure the agent experience, App Home, and event
diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx
new file mode 100644
index 000000000..cf08e8764
--- /dev/null
+++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx
@@ -0,0 +1,92 @@
+// @vitest-environment jsdom
+///
+
+import { Suspense } from "react";
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
+import * as matchers from "@testing-library/jest-dom/matchers";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import EditAutomationPage from "./page";
+
+expect.extend(matchers);
+
+const CURRENT_USER_ID = "11111111111111111111111111111111";
+let permissions: string[] = [];
+const replace = vi.fn();
+
+const automation = {
+ id: "auto-1",
+ name: "Nightly review",
+ instructions: "Review the code",
+ triggerType: "schedule" as const,
+ scheduleCron: "0 9 * * *",
+ scheduleTz: "UTC",
+ model: "anthropic/claude-sonnet-4-6",
+ reasoningEffort: null,
+ enabled: true,
+ nextRunAt: null,
+ consecutiveFailures: 0,
+ createdBy: CURRENT_USER_ID,
+ userId: "22222222222222222222222222222222",
+ createdAt: 1,
+ updatedAt: 1,
+ deletedAt: null,
+ eventType: null,
+ triggerConfig: null,
+ repositories: [],
+ environmentIds: [],
+ providerSelections: {},
+};
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace }),
+}));
+vi.mock("@/components/sidebar-layout", () => ({
+ CollapsedSidebarControls: () => null,
+ useSidebarContext: () => ({ isOpen: true }),
+}));
+vi.mock("@/hooks/use-automations", () => ({
+ useAutomation: () => ({ automation, loading: false }),
+}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ authorization: { userId: CURRENT_USER_ID, permissions },
+ loading: false,
+ }),
+}));
+vi.mock("@/components/automations/automation-form", () => ({
+ AutomationForm: () =>
);
}
export function SidebarLayout({ children }: SidebarLayoutProps) {
const router = useRouter();
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canCreateSession = hasPermission("sessions.create");
const sidebar = useSidebar();
const isMobile = useIsMobile();
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
@@ -95,12 +99,13 @@ export function SidebarLayout({ children }: SidebarLayoutProps) {
);
const handleNewSession = useCallback(() => {
+ if (!canCreateSession) return;
setIsCommandMenuOpen(false);
if (isMobile) {
sidebar.close();
}
router.push("/");
- }, [isMobile, router, sidebar]);
+ }, [canCreateSession, isMobile, router, sidebar]);
const handleNavigate = useCallback(
(href: string) => {
diff --git a/packages/web/src/components/sidebar/metadata-section.tsx b/packages/web/src/components/sidebar/metadata-section.tsx
index 9b16187a0..4d56aa061 100644
--- a/packages/web/src/components/sidebar/metadata-section.tsx
+++ b/packages/web/src/components/sidebar/metadata-section.tsx
@@ -52,6 +52,7 @@ interface MetadataSectionProps {
warnings?: WarningEvent[];
parentSessionId?: string | null;
totalCost?: number;
+ canManageLifecycle?: boolean;
}
/**
@@ -108,12 +109,13 @@ export function MetadataSection({
warnings = [],
parentSessionId,
totalCost,
+ canManageLifecycle = true,
}: MetadataSectionProps) {
const [copied, setCopied] = useState(false);
const isMultiRepo = (repositories?.length ?? 0) > 1;
const hasPrArtifact = artifacts.some((a) => a.type === "pr");
- const showSyncButton = Boolean(sessionId) && hasPrArtifact;
+ const showSyncButton = canManageLifecycle && Boolean(sessionId) && hasPrArtifact;
// Sessions can hold several PRs (one open PR per head branch); list them
// all, oldest first — creation order matches PR-number order.
diff --git a/packages/web/src/hooks/use-global-shortcuts.test.tsx b/packages/web/src/hooks/use-global-shortcuts.test.tsx
index 68b8b24ef..3b0507bcc 100644
--- a/packages/web/src/hooks/use-global-shortcuts.test.tsx
+++ b/packages/web/src/hooks/use-global-shortcuts.test.tsx
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_KEYBOARD_SHORTCUTS } from "@open-inspect/shared/types/keyboard-shortcuts";
import { useGlobalShortcuts } from "./use-global-shortcuts";
+const mocks = vi.hoisted(() => ({ canCreateSession: true }));
+
const shortcuts = {
...DEFAULT_KEYBOARD_SHORTCUTS,
"open-command-menu": { code: "KeyP", primary: true, alt: false, shift: false },
@@ -16,8 +18,18 @@ vi.mock("@/hooks/use-keyboard-shortcuts", () => ({
useKeyboardShortcuts: () => ({ shortcuts }),
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ permission === "sessions.create" && mocks.canCreateSession,
+ }),
+}));
+
describe("useGlobalShortcuts", () => {
- afterEach(() => vi.restoreAllMocks());
+ afterEach(() => {
+ mocks.canCreateSession = true;
+ vi.restoreAllMocks();
+ });
it("dispatches the configured action and removes its listener", () => {
const onOpenCommandMenu = vi.fn();
@@ -46,4 +58,27 @@ describe("useGlobalShortcuts", () => {
window.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyP", ctrlKey: true }));
expect(onOpenCommandMenu).toHaveBeenCalledOnce();
});
+
+ it("ignores the new session shortcut without session creation permission", () => {
+ mocks.canCreateSession = false;
+ const onNewSession = vi.fn();
+ renderHook(() =>
+ useGlobalShortcuts({
+ onOpenCommandMenu: vi.fn(),
+ onNewSession,
+ onToggleSidebar: vi.fn(),
+ })
+ );
+
+ const event = new KeyboardEvent("keydown", {
+ code: "KeyN",
+ ctrlKey: true,
+ shiftKey: true,
+ cancelable: true,
+ });
+ window.dispatchEvent(event);
+
+ expect(onNewSession).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(false);
+ });
});
diff --git a/packages/web/src/hooks/use-global-shortcuts.ts b/packages/web/src/hooks/use-global-shortcuts.ts
index 33446518c..2f5d41355 100644
--- a/packages/web/src/hooks/use-global-shortcuts.ts
+++ b/packages/web/src/hooks/use-global-shortcuts.ts
@@ -3,6 +3,7 @@
import { useEffect } from "react";
import { matchGlobalShortcut, shouldIgnoreGlobalShortcutForAction } from "@/lib/keyboard-shortcuts";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
interface UseGlobalShortcutsOptions {
enabled?: boolean;
@@ -18,6 +19,8 @@ export function useGlobalShortcuts({
onToggleSidebar,
}: UseGlobalShortcutsOptions) {
const { shortcuts } = useKeyboardShortcuts();
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canCreateSession = hasPermission("sessions.create");
useEffect(() => {
if (!enabled) return;
@@ -25,6 +28,7 @@ export function useGlobalShortcuts({
const action = matchGlobalShortcut(event, shortcuts);
if (!action) return;
if (shouldIgnoreGlobalShortcutForAction(event, action)) return;
+ if (action === "new-session" && !canCreateSession) return;
event.preventDefault();
@@ -35,5 +39,5 @@ export function useGlobalShortcuts({
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
- }, [enabled, onNewSession, onOpenCommandMenu, onToggleSidebar, shortcuts]);
+ }, [canCreateSession, enabled, onNewSession, onOpenCommandMenu, onToggleSidebar, shortcuts]);
}
diff --git a/packages/web/src/hooks/use-sandbox-access.ts b/packages/web/src/hooks/use-sandbox-access.ts
index d28b3a1d5..28c943041 100644
--- a/packages/web/src/hooks/use-sandbox-access.ts
+++ b/packages/web/src/hooks/use-sandbox-access.ts
@@ -22,10 +22,11 @@ const sandboxAccessSchema = z
type SandboxAccess = z.infer;
-export function useSandboxAccess(sessionId: string, isSandboxReady: boolean) {
- const key: BrowserApiPath | null = isSandboxReady
- ? `/api/sessions/${encodeURIComponent(sessionId)}/sandbox-access`
- : null;
+export function useSandboxAccess(sessionId: string, isSandboxReady: boolean, enabled = true) {
+ const key: BrowserApiPath | null =
+ enabled && isSandboxReady
+ ? `/api/sessions/${encodeURIComponent(sessionId)}/sandbox-access`
+ : null;
const { data, mutate } = useSWR(key, async (url: BrowserApiPath) => {
const response = await browserApiFetch(url, { cache: "no-store" });
if (response.status === 204 || response.status === 404) return null;
diff --git a/packages/web/src/hooks/use-session-socket.test.tsx b/packages/web/src/hooks/use-session-socket.test.tsx
index 477625509..d08cf333f 100644
--- a/packages/web/src/hooks/use-session-socket.test.tsx
+++ b/packages/web/src/hooks/use-session-socket.test.tsx
@@ -139,6 +139,28 @@ describe("useSessionSocket", () => {
vi.restoreAllMocks();
});
+ it("keeps the HTTP snapshot available without collaboration or sandbox requests", async () => {
+ const fetchMock = vi.mocked(fetch);
+ const snapshot = createSnapshot();
+ snapshot.session.title = "Read-only snapshot";
+
+ const { result } = renderHook(() =>
+ useSessionSocket("session-1", snapshot, {
+ collaborate: false,
+ sandboxAccess: false,
+ })
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(result.current.sessionState?.title).toBe("Read-only snapshot");
+ expect(result.current.connected).toBe(false);
+ expect(FakeWebSocket.instances).toHaveLength(0);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
it("keeps sendPrompt pending until the server acknowledges the queued prompt", async () => {
const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot()));
diff --git a/packages/web/src/hooks/use-session-socket.ts b/packages/web/src/hooks/use-session-socket.ts
index 72ef39868..ec9d622c6 100644
--- a/packages/web/src/hooks/use-session-socket.ts
+++ b/packages/web/src/hooks/use-session-socket.ts
@@ -99,7 +99,11 @@ interface PendingCorrelatedRequest {
*/
export function useSessionSocket(
sessionId: string,
- initialSnapshot: SessionSnapshot
+ initialSnapshot: SessionSnapshot,
+ capabilities: { collaborate: boolean; sandboxAccess: boolean } = {
+ collaborate: true,
+ sandboxAccess: true,
+ }
): UseSessionSocketReturn {
const [state, dispatch] = useReducer(
sessionSocketReducer,
@@ -117,7 +121,11 @@ export function useSessionSocket(
sandboxAccess,
clear: clearSandboxAccess,
refresh: refreshSandboxAccess,
- } = useSandboxAccess(sessionId, state.sessionState?.sandboxStatus === "ready");
+ } = useSandboxAccess(
+ sessionId,
+ state.sessionState?.sandboxStatus === "ready",
+ capabilities.sandboxAccess
+ );
const settleSubscriptionWaiters = useCallback((subscribed: boolean) => {
for (const resolve of subscriptionWaitersRef.current) {
@@ -228,10 +236,14 @@ export function useSessionSocket(
dispatch({ type: "socket_closed" });
}, [settleAllCorrelatedRequests, settleSubscriptionWaiters]);
- const transport = useSessionTransport(sessionId, {
- onMessage: handleMessage,
- onClose: handleClose,
- });
+ const transport = useSessionTransport(
+ sessionId,
+ {
+ onMessage: handleMessage,
+ onClose: handleClose,
+ },
+ capabilities.collaborate
+ );
const { isOpen, send, reconnect, markHealthy } = transport;
useEffect(() => {
diff --git a/packages/web/src/hooks/use-session-transport.test.tsx b/packages/web/src/hooks/use-session-transport.test.tsx
index 94be721e5..9bdd2f1f6 100644
--- a/packages/web/src/hooks/use-session-transport.test.tsx
+++ b/packages/web/src/hooks/use-session-transport.test.tsx
@@ -110,6 +110,25 @@ describe("useSessionTransport", () => {
expect(result.current.isOpen()).toBe(true);
});
+ it("does not fetch a token or open a socket when transport is disabled", async () => {
+ const { result } = renderHook(() =>
+ useSessionTransport("session-1", { onMessage, onClose }, false)
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(FakeWebSocket.instances).toHaveLength(0);
+ expect(result.current.connected).toBe(false);
+ expect(result.current.connecting).toBe(false);
+
+ act(() => result.current.reconnect());
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(FakeWebSocket.instances).toHaveLength(0);
+ });
+
it("forwards schema-valid messages to onMessage", async () => {
const { socket } = await openSocket();
@@ -195,6 +214,26 @@ describe("useSessionTransport", () => {
expect(FakeWebSocket.instances).toHaveLength(1);
});
+ it("clears the token and reconnects after authorization lease expiry", async () => {
+ vi.useFakeTimers();
+ const rendered = renderTransport();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ act(() => {
+ FakeWebSocket.instances[0].open();
+ FakeWebSocket.instances[0].serverClose(4010, true);
+ });
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000);
+ });
+
+ expect(FakeWebSocket.instances).toHaveLength(2);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ rendered.unmount();
+ });
+
it("reconnects with backoff after an unclean close and reuses the cached token", async () => {
vi.useFakeTimers();
const rendered = renderTransport();
diff --git a/packages/web/src/hooks/use-session-transport.ts b/packages/web/src/hooks/use-session-transport.ts
index d7453f009..9118cad0b 100644
--- a/packages/web/src/hooks/use-session-transport.ts
+++ b/packages/web/src/hooks/use-session-transport.ts
@@ -19,6 +19,7 @@ const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8787";
const WS_CLOSE_AUTH_REQUIRED = 4001;
const WS_CLOSE_SESSION_EXPIRED = 4002;
const WS_CLOSE_INVALID_MESSAGE = 4004;
+const WS_CLOSE_AUTHORIZATION_REVOKED = 4010;
const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_BASE_DELAY_MS = 1000;
@@ -33,6 +34,7 @@ function reconnectDelayMs(attemptsSoFar: number): number {
type CloseDirective =
| { action: "auth_required" }
| { action: "session_expired" }
+ | { action: "authorization_revoked"; delayMs?: number }
| { action: "retry"; delayMs: number }
| { action: "give_up" }
| { action: "none" };
@@ -47,6 +49,11 @@ function closeDirective(
if (event.code === WS_CLOSE_SESSION_EXPIRED) {
return { action: "session_expired" };
}
+ if (event.code === WS_CLOSE_AUTHORIZATION_REVOKED) {
+ return attemptsSoFar < MAX_RECONNECT_ATTEMPTS
+ ? { action: "authorization_revoked", delayMs: reconnectDelayMs(attemptsSoFar) }
+ : { action: "authorization_revoked" };
+ }
if (!event.wasClean || event.code === WS_CLOSE_INVALID_MESSAGE) {
return attemptsSoFar < MAX_RECONNECT_ATTEMPTS
? { action: "retry", delayMs: reconnectDelayMs(attemptsSoFar) }
@@ -85,7 +92,8 @@ export interface UseSessionTransportReturn {
*/
export function useSessionTransport(
sessionId: string,
- handlers: SessionTransportHandlers
+ handlers: SessionTransportHandlers,
+ enabled = true
): UseSessionTransportReturn {
const wsRef = useRef(null);
const mountedRef = useRef(true);
@@ -234,6 +242,19 @@ export function useSessionTransport(
wsTokenRef.current = null;
return;
+ case "authorization_revoked":
+ wsTokenRef.current = null;
+ if (!mountedRef.current) return;
+ if (directive.delayMs === undefined) {
+ setConnectionError("Authorization could not be refreshed. Please try reconnecting.");
+ return;
+ }
+ reconnectAttempts.current++;
+ reconnectTimeoutRef.current = setTimeout(() => {
+ if (mountedRef.current) retry();
+ }, directive.delayMs);
+ return;
+
case "retry":
if (!mountedRef.current) return;
reconnectAttempts.current++;
@@ -323,6 +344,7 @@ export function useSessionTransport(
}, []);
const reconnect = useCallback(() => {
+ if (!enabled) return;
// A connect() still awaiting its token must not open a second socket
// alongside the one this call creates.
invalidateInFlightConnect();
@@ -344,7 +366,7 @@ export function useSessionTransport(
setAuthError(null);
setConnectionError(null);
connect();
- }, [connect, invalidateInFlightConnect]);
+ }, [connect, enabled, invalidateInFlightConnect]);
const markHealthy = useCallback(() => {
reconnectAttempts.current = 0;
@@ -353,7 +375,7 @@ export function useSessionTransport(
// Connect on mount
useEffect(() => {
mountedRef.current = true;
- connect();
+ if (enabled) connect();
return () => {
mountedRef.current = false;
@@ -367,10 +389,11 @@ export function useSessionTransport(
discarded.close();
}
};
- }, [connect, invalidateInFlightConnect]);
+ }, [connect, enabled, invalidateInFlightConnect]);
// Ping periodically to keep connection alive.
useEffect(() => {
+ if (!enabled) return;
const pingInterval = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "ping" }));
@@ -378,7 +401,7 @@ export function useSessionTransport(
}, PING_INTERVAL_MS);
return () => clearInterval(pingInterval);
- }, []);
+ }, [enabled]);
return {
connected,
diff --git a/packages/web/src/lib/automation-authorization.test.ts b/packages/web/src/lib/automation-authorization.test.ts
new file mode 100644
index 000000000..6b4ad58db
--- /dev/null
+++ b/packages/web/src/lib/automation-authorization.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import type { EffectiveAuthorization, PermissionId } from "@open-inspect/shared/rbac";
+import { canAccessAutomation } from "./automation-authorization";
+
+const CURRENT_USER_ID = "11111111111111111111111111111111";
+const OTHER_USER_ID = "22222222222222222222222222222222";
+
+function authorization(permissions: PermissionId[]): EffectiveAuthorization {
+ return {
+ userId: CURRENT_USER_ID,
+ suspendedAt: null,
+ role: { id: "role-1", key: null, name: "Test" },
+ permissions,
+ };
+}
+
+describe("canAccessAutomation", () => {
+ it("allows any scope regardless of ownership", () => {
+ expect(
+ canAccessAutomation("automations.manage", authorization(["automations.manage.any"]), {
+ userId: OTHER_USER_ID,
+ })
+ ).toBe(true);
+ });
+
+ it("allows own scope only for the canonical owner", () => {
+ const auth = authorization(["automations.trigger.own"]);
+ expect(canAccessAutomation("automations.trigger", auth, { userId: CURRENT_USER_ID })).toBe(
+ true
+ );
+ expect(canAccessAutomation("automations.trigger", auth, { userId: OTHER_USER_ID })).toBe(false);
+ expect(canAccessAutomation("automations.trigger", auth, { userId: null })).toBe(false);
+ });
+
+ it("denies missing authorization and unrelated capabilities", () => {
+ expect(canAccessAutomation("automations.manage", null, { userId: CURRENT_USER_ID })).toBe(
+ false
+ );
+ expect(
+ canAccessAutomation("automations.manage", authorization(["automations.trigger.any"]), {
+ userId: CURRENT_USER_ID,
+ })
+ ).toBe(false);
+ });
+});
diff --git a/packages/web/src/lib/automation-authorization.ts b/packages/web/src/lib/automation-authorization.ts
new file mode 100644
index 000000000..798f7e2dc
--- /dev/null
+++ b/packages/web/src/lib/automation-authorization.ts
@@ -0,0 +1,17 @@
+import {
+ resolveScopedPermission,
+ type EffectiveAuthorization,
+ type ScopedPermissionStem,
+} from "@open-inspect/shared/rbac";
+import type { Automation } from "@open-inspect/shared/types/automations";
+
+/** Checks an automation capability against its canonical owner identity. */
+export function canAccessAutomation(
+ stem: ScopedPermissionStem,
+ authorization: EffectiveAuthorization | null,
+ automation: Pick
+): boolean {
+ if (!authorization) return false;
+ const scope = resolveScopedPermission(stem, authorization.permissions);
+ return scope === "any" || (scope === "own" && automation.userId === authorization.userId);
+}
diff --git a/public/docs/internal/2026-08-28-rbac-design.md b/public/docs/internal/2026-08-28-rbac-design.md
new file mode 100644
index 000000000..626d894e1
--- /dev/null
+++ b/public/docs/internal/2026-08-28-rbac-design.md
@@ -0,0 +1,815 @@
+# Design: Role-Based Access Control
+
+**Date:** 2026-08-28
+
+**Status:** Proposed
+
+**Research:** [2026-08-28-rbac-research.md](./2026-08-28-rbac-research.md)
+
+## Summary
+
+Open-Inspect will add workspace-level RBAC to its existing single-installation identity model. Each
+canonical human user is assigned exactly one role. A role contains a set of permissions selected
+from a code-owned registry. Four protected built-in roles provide safe defaults. The storage and
+resolution model also supports existing custom roles, but custom-role creation and editing are
+deferred beyond this foundation.
+
+Authorization will be enforced in the control plane after authentication and before business logic.
+The web will receive effective permissions for navigation and control affordances, but client checks
+will remain advisory. Sessions are workspace-wide resources governed by operation permissions, as
+specified in
+[Workspace-Wide Session Authorization](./2026-08-30-workspace-wide-session-authorization-design.md).
+Bot calls will be limited by both a fixed service capability ceiling and, when acting for a human,
+that canonical user's current role.
+
+This design retains one workspace per deployment. It does not add multiple organizations or
+per-repository user grants. The SCM App installation continues to define the repository universe;
+RBAC determines which application actions a user may perform within that universe.
+
+## Goals
+
+- Assign different capability sets to individual canonical users.
+- Provide protected Owner, Administrator, Member, and Viewer roles.
+- Resolve and assign persisted custom roles from a fixed permission registry.
+- Enforce permissions consistently across HTTP routes, session WebSockets, bots, and settings.
+- Distinguish authentication, admission, attribution, resource relationships, and authorization.
+- Preserve existing installation access during migration without leaving the workspace ownerless.
+- Make role assignment and privileged operations durably auditable.
+- Apply role changes promptly to new requests and bounded-lifetime live connections.
+- Keep the authorization API explicit, typed, testable, and deny-by-default.
+
+## Non-Goals
+
+- Multiple workspaces or organizations in one deployment.
+- User/group grants for individual repositories or environments.
+- Synchronizing roles from GitHub, Google, Slack, Linear, or an identity provider.
+- Treating source-control permissions as Open-Inspect roles.
+- A general policy language, conditional expressions, deny rules, or arbitrary customer-defined
+ permission identifiers.
+- Billing plans, quotas, approval workflows, or separation-of-duty constraints.
+- Modeling Cloudflare, Modal, Terraform, or GitHub deployment operators as application users.
+- Changing sandbox-to-control-plane or control-plane-to-Modal machine authentication.
+- Making secret values readable after storage.
+
+## Terminology
+
+| Term | Meaning |
+| --------------------- | --------------------------------------------------------------------------------- |
+| Workspace | The singleton administrative boundary represented by one Open-Inspect deployment. |
+| Principal | An authenticated human user, first-party service, or session-bound sandbox. |
+| Actor | A provider identity asserted by a bot service on behalf of a human. |
+| Role | A named collection of registered permissions. |
+| Built-in role | A protected role shipped by the application with code-defined permissions. |
+| Custom role | A workspace-defined role composed from registered permissions. |
+| Permission | A stable `resource.action` identifier checked by backend policy. |
+| Relationship | Context such as automation ownership used alongside a scoped permission. |
+| Capability ceiling | The maximum permission set a first-party service can exercise. |
+| Effective permissions | The permissions produced by the current role, bounded by principal policy. |
+
+## Decisions
+
+| Area | Decision |
+| ---------------- | ---------------------------------------------------------------------------------------- |
+| Tenancy | One implicit workspace per deployment. |
+| User assignment | Exactly one role per canonical user. |
+| Role model | Four protected built-ins plus custom roles. |
+| Permission model | Fixed allow-only registry owned in shared code. Missing permission denies. |
+| Enforcement | Control plane is authoritative; web checks are presentation only. |
+| Resource scoping | Workspace-wide sessions plus contextual own/any automation actions. |
+| Repository scope | SCM installation defines visibility; role permissions govern app operations. |
+| Services | Static service ceilings; actor-backed calls use ceiling/actor intersection. |
+| Sandboxes | Existing session-bound capability model remains separate from human RBAC. |
+| Role changes | Immediate for HTTP; short authorization leases bound live browser connections. |
+| Audit | Durable audit events for RBAC changes and sensitive mutations; structured denial logs. |
+| Owner bootstrap | Every deployment requires an explicit operator bootstrap after the Owner signs in. |
+| Migration | Existing canonical users become Administrator; the operator explicitly bootstraps Owner. |
+
+## Authorization Model
+
+### Built-in roles
+
+The built-in roles are stable system records. Their names and permission sets are defined in code
+and cannot be deleted or edited through the application.
+
+| Role | Intended capability |
+| ------------- | --------------------------------------------------------------------------------------------- |
+| Owner | Full application access, role management, member management, and ownership transfer. |
+| Administrator | Full operational access except ownership transfer and protected Owner changes. |
+| Member | Create and operate sessions and automations; use shared targets; no sensitive administration. |
+| Viewer | Read shared operational state and session output; no launches or shared-resource mutations. |
+
+Owner is not represented by a wildcard. It receives every registered permission explicitly when
+permissions are resolved. This makes newly introduced permissions visible in review and prevents
+custom permission strings from becoming executable.
+
+### Custom roles
+
+The data model and permission resolver retain support for persisted custom roles so assignments and
+effective authorization do not depend on built-in role keys. This foundation exposes custom roles
+through read and assignment APIs only; creating, editing, and deleting them is deferred until there
+is a concrete administration workflow. Persisted custom permissions must be registry members, cannot
+include `workspace.transfer_ownership`, and remain allow-only without inheritance or deny entries.
+
+One role per user avoids ambiguous permission union, ordering, and deny precedence. A later group or
+multi-role system can expand assignment cardinality without changing permission identifiers or route
+checks.
+
+### Permission registry
+
+Permissions are exported from `@open-inspect/shared` as stable identifiers and protected built-in
+role sets. Built-in policy changes deploy with code and do not require a data migration. Persisted
+`role_permissions` rows are the runtime authority only for workspace-defined custom roles. Unknown
+identifiers fail role validation and are ignored during effective-permission resolution. Permission
+IDs are never reused for different semantics.
+
+### Permission catalog
+
+#### Workspace and identity
+
+| Permission | Actions |
+| ------------------------------ | --------------------------------------------------------------------- |
+| `workspace.members.read` | List users, identities, roles, and assignment state. |
+| `workspace.members.manage` | Assign roles other than Owner; suspend or restore application access. |
+| `workspace.roles.read` | List role definitions and permission catalog. |
+| `workspace.transfer_ownership` | Assign/remove Owner while preserving at least one Owner. |
+
+#### Repositories and environments
+
+| Permission | Actions |
+| ------------------------------ | ----------------------------------------------------------------- |
+| `repositories.read` | List installed repositories, branches, and metadata. |
+| `repositories.use` | Select repositories as session or automation targets. |
+| `repositories.settings.manage` | Change repository SCM, sandbox, and integration overrides. |
+| `repositories.secrets.manage` | Create, update, or delete repository secrets. |
+| `repositories.images.manage` | Toggle or trigger repository image builds. |
+| `environments.read` | List and inspect environments and memberships. |
+| `environments.use` | Select environments as session or automation targets. |
+| `environments.manage` | Create, update, or delete environments and repository membership. |
+| `environments.settings.manage` | Change environment integration and sandbox overrides. |
+| `environments.secrets.manage` | Create, update, delete, or import environment secrets. |
+| `environments.images.manage` | Toggle or trigger environment image builds. |
+
+#### Sessions
+
+| Permission | Actions |
+| ------------------------- | --------------------------------------------------------------------- |
+| `sessions.create` | Create a session using an allowed target. |
+| `sessions.read` | Read every workspace session. |
+| `sessions.collaborate` | Prompt, attach files, and connect to every workspace session. |
+| `sessions.lifecycle` | Rename, archive, unarchive, stop, cancel, and refresh any session. |
+| `sessions.delete` | Delete any workspace session. |
+| `sessions.sandbox_access` | Obtain terminal, VNC, code-server, or sandbox access for any session. |
+
+Session creator and participant data are attribution and runtime identity, not authorization.
+Read-state changes require `sessions.read` and always mutate only the caller's own read state.
+
+#### Automations and analytics
+
+| Permission | Actions |
+| ------------------------- | ---------------------------------------------------------------------------- |
+| `automations.read` | List automation definitions and run history. |
+| `automations.create` | Create an automation with allowed targets and provider mode. |
+| `automations.manage.own` | Edit, pause, resume, rotate keys, or delete automations created by the user. |
+| `automations.manage.any` | Manage any automation. |
+| `automations.trigger.own` | Manually execute an automation created by the user. |
+| `automations.trigger.any` | Manually execute any automation. |
+| `analytics.read` | View installation-wide session, repository, user, and PR analytics. |
+
+#### Models, integrations, and execution configuration
+
+| Permission | Actions |
+| --------------------------- | -------------------------------------------------------------------------- |
+| `models.preferences.manage` | Change enabled model preferences. |
+| `provider_accounts.read` | View provider account metadata, status, and defaults. |
+| `provider_accounts.manage` | Connect, reconnect, rename, verify, enable, disable, and default accounts. |
+| `integrations.read` | View integration, SCM, sandbox, and commit-signing metadata. |
+| `integrations.manage` | Change global integration and sandbox settings. |
+| `scm_settings.manage` | Change deployment-wide SCM settings. |
+| `commit_signing.manage` | Configure or remove deployment-wide signing material. |
+| `global_secrets.manage` | Create, update, or delete global secrets. |
+| `image_builds.read` | View repository/environment image build status and history. |
+
+#### Extensibility
+
+| Permission | Actions |
+| --------------------------- | ------------------------------------------------------------------------- |
+| `skills.read` | List shared managed skills. |
+| `skills.manage` | Import, edit, assign, reimport, enable, disable, or delete shared skills. |
+| `skill_profiles.manage_own` | Manage only the caller's skill profiles. |
+| `mcp_servers.read` | List MCP server definitions. |
+| `mcp_servers.manage` | Create, update, or delete MCP server definitions. |
+
+Personal keyboard shortcuts and browser-local appearance require only an authenticated, active user.
+They do not need role permissions because they cannot affect another user or shared execution.
+
+### Default role matrix
+
+The table groups permissions for readability; the registry stores individual identifiers.
+
+| Capability group | Owner | Administrator | Member | Viewer |
+| -------------------------------------------------------- | :---: | :-----------: | :------: | :----: |
+| Workspace, member, role, and audit read | Yes | Yes | No | No |
+| Manage members | Yes | Yes | No | No |
+| Transfer Owner role | Yes | No | No | No |
+| Read repositories and environments | Yes | Yes | Yes | Yes |
+| Use repositories and environments | Yes | Yes | Yes | No |
+| Read image-build status and history | Yes | Yes | Yes | Yes |
+| Manage environments/settings/images | Yes | Yes | No | No |
+| Manage global/repository/environment secrets | Yes | Yes | No | No |
+| Create sessions | Yes | Yes | Yes | No |
+| Read any session | Yes | Yes | Yes | Yes |
+| Collaborate in any session | Yes | Yes | Yes | No |
+| Perform session lifecycle operations | Yes | Yes | Yes | No |
+| Delete sessions | Yes | Yes | Yes | No |
+| Obtain sandbox access | Yes | Yes | Yes | No |
+| Read automations | Yes | Yes | Yes | Yes |
+| Create/manage/trigger automations | Yes | Yes | Own only | No |
+| Read analytics | Yes | Yes | Yes | Yes |
+| Manage models/provider accounts/integrations/SCM/signing | Yes | Yes | No | No |
+| Read shared skills and MCP servers | Yes | Yes | Yes | Yes |
+| Manage shared skills and MCP servers | Yes | Yes | No | No |
+| Manage own skill profiles | Yes | Yes | Yes | No |
+| Manage personal preferences | Yes | Yes | Yes | Yes |
+
+Viewer receives `sessions.read` but no collaborate or lifecycle permission. Member receives every
+non-administrative session operation across the workspace. Administrator preserves the existing
+broad operational behavior.
+
+## Data Model
+
+### Tables
+
+```sql
+CREATE TABLE roles (
+ id TEXT PRIMARY KEY,
+ key TEXT UNIQUE,
+ name TEXT NOT NULL,
+ normalized_name TEXT NOT NULL UNIQUE,
+ description TEXT,
+ is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0, 1)),
+ CHECK ((is_system = 1 AND key IN ('owner', 'administrator', 'member', 'viewer'))
+ OR (is_system = 0 AND key IS NULL))
+);
+
+CREATE TABLE role_permissions (
+ role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
+ permission_id TEXT NOT NULL,
+ PRIMARY KEY (role_id, permission_id)
+);
+
+CREATE TABLE user_role_assignments (
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE RESTRICT,
+ role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE RESTRICT
+);
+
+CREATE TABLE authorization_audit_events (
+ id TEXT PRIMARY KEY,
+ occurred_at INTEGER NOT NULL,
+ request_id TEXT NOT NULL,
+ principal_kind TEXT NOT NULL,
+ actor_user_id_snapshot TEXT,
+ actor_service_snapshot TEXT,
+ action TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ resource_id TEXT,
+ target_user_id_snapshot TEXT,
+ reason_code TEXT NOT NULL
+);
+
+CREATE INDEX idx_role_assignments_role ON user_role_assignments(role_id, user_id);
+```
+
+Built-in roles have stable `key` values: `owner`, `administrator`, `member`, and `viewer`; their
+permission sets come from the shared code registry and have no `role_permissions` rows. Custom roles
+have `key = NULL`, and their permission rows are the runtime authority. IDs are opaque; role names
+are display values. This foundation does not expose custom-role mutations.
+
+`users` gains:
+
+```sql
+ALTER TABLE users ADD COLUMN suspended_at INTEGER;
+```
+
+Suspension records the time access was disabled without deleting identities or historical
+attribution. A null value means the user is active.
+
+Every canonical identity is an active workspace member unless suspended. The RBAC migration seeds
+the built-in roles, assigns Administrator to every existing canonical user, and then creates the
+default-role trigger. Every identity created afterward receives Member, including identities first
+observed through a bot. Identity creation and default role assignment are one database-triggered
+workflow. Authorization denies a missing assignment; ordinary sign-in and identity resolution never
+repair authorization corruption implicitly.
+
+Initial ownership is assigned only by the root operator CLI after the intended Owner has signed in
+once. The operator supplies the canonical user ID, not an email or browser credential. One temporary
+SQL file and one Wrangler D1 execution validate the RBAC schema, unsuspended user, exact assignment,
+and absence of another unsuspended Owner before atomically writing a redacted `operator-cli` audit
+event and assigning `role_builtin_owner`. The final SQL guard verifies the exact generated audit ID
+and aborts the operation if the resulting state is inconsistent. Re-running for the current
+unsuspended Owner is a no-op and writes nothing. Ownership changes after initialization use the
+authenticated member API.
+
+### Storage ownership
+
+- D1 is the source of truth for roles, assignments, status, custom-role grants, and audit events.
+- Shared code defines the permission catalog and built-in role grants; persisted permission rows are
+ the runtime grant authority for custom roles.
+- Session creator attribution remains in D1 and is not an authorization relationship.
+- Participant attribution remains in the Session Durable Object for message identity, presence, SCM
+ metadata, and WebSocket tokens.
+- No role or permission set is copied into sessions, automations, or provider accounts.
+
+## Policy Engine
+
+### Interface
+
+Authorization is invoked through one control-plane service rather than direct role-table queries in
+handlers:
+
+```ts
+type AuthorizationRequest = {
+ principal: Principal;
+ permission: PermissionId;
+ resource?: AuthorizationResource;
+};
+
+type AuthorizationDecision = {
+ allowed: boolean;
+ reason: AuthorizationReason;
+ actorUserId: string | null;
+};
+```
+
+The engine exposes `requirePermission()` for ordinary checks and an automation resource helper for
+owner-scoped automation policy. Denial throws a typed `403` error with a stable reason code.
+Authentication failures remain `401`; missing resources remain `404` after permission admission.
+
+### Human decision flow
+
+1. Require an active canonical user.
+2. Load the user's role assignment and registered permission set.
+3. Deny if no assignment exists.
+4. Check the requested permission.
+5. For owner-scoped automation permissions, load the automation owner.
+6. Return an allow/deny decision with a stable reason.
+
+### Service decision flow
+
+Each service has a code-defined ceiling:
+
+- `web` may proxy browser-auth and discovery operations only; browser application routes authorize
+ the human user principal produced by composed authentication.
+- `github-bot` may read repository/environment launch metadata, create sessions, read, prompt, or
+ stop workspace sessions, and post GitHub automation events.
+- `slack-bot` may read launch catalogs/preferences, create sessions, operate sessions mapped to its
+ Slack thread, upload/download session media, and post Slack events.
+- `linear-bot` may read launch catalogs/preferences, create sessions, and operate sessions mapped to
+ its Linear issue/agent session.
+
+For an actor-backed service request:
+
+```text
+effective = service ceiling ∩ actor role permissions
+```
+
+The actor must resolve to an active canonical user with a role assignment. Service-authenticated
+identity enrollment resolves or creates the canonical identity before business authorization and
+idempotently assigns the migration default: Administrator for identities captured by the migration,
+Member afterward. A first bot interaction can therefore proceed with Member capabilities but can
+never claim Owner. Provider webhook verification and GitHub collaborator checks remain additional
+admission conditions, never substitutes for application authorization.
+
+Actorless callbacks, normalized webhook events, and automation triggers use narrow service-only
+permissions declared for their exact endpoints. They cannot use broad `user-or-service` management
+routes.
+
+### Sandbox decision flow
+
+Sandbox authentication remains a scoped capability. A valid sandbox principal can call only route
+operations explicitly designated for a sandbox bound to the same session. It does not inherit the
+session creator's role and does not gain workspace permissions. Human role changes do not terminate
+an executing sandbox, but they can remove human access to its session and controls.
+
+### Session authorization and identity
+
+Session operations are workspace-scoped. A user with a session operation permission may apply it to
+every session, regardless of creator or participant identity. Deletion is also workspace-scoped.
+
+`sessions.user_id` retains immutable creator attribution for display, filtering, auditing, and
+credential lineage. Session Durable Object participants retain message identity, presence, SCM
+metadata, and WebSocket token ownership. Neither is an authorization grant.
+
+Creating a WebSocket token or sending a prompt requires `sessions.collaborate`. WebSocket
+subscription rechecks the represented canonical user's active role and collaboration permission.
+Private, invitation-only, participant-restricted, and creator-only session behavior is deferred.
+
+### Automation execution authority
+
+Automation definitions retain a canonical owner. Every invocation reauthorizes current state rather
+than replaying stored creator authority:
+
+| Trigger | Initiating actor | Execution principal | Required current authority |
+| ------------ | ----------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------- |
+| Manual | Requesting user | Requesting user | own/any trigger, target use, session create |
+| Schedule | Scheduler service | Automation owner | active owner, manage-own, target use, session create |
+| Webhook key | Narrow webhook capability | Automation owner | active owner, manage-own, target use, session create |
+| Sentry | Verified Sentry webhook | Automation owner | active owner, manage-own, target use, session create |
+| GitHub event | Verified GitHub service actor | Canonical GitHub actor | service ceiling; active actor with session create and target use; active owner with manage-own |
+| Slack event | Verified Slack service actor | Canonical Slack actor | service ceiling; active actor with session create and target use; active owner with manage-own |
+| Linear event | Verified Linear service actor | Canonical Linear actor | service ceiling; active actor with session create and target use; active owner with manage-own |
+
+The resulting session is owned by and attributed to the named canonical execution principal. The
+initiator, service, and automation owner are recorded separately in invocation/audit metadata. Skill
+profiles and user-linked credentials come from the execution principal; installation-wide secrets
+and provider accounts remain selected by the automation's current allowed configuration. A manual
+trigger never runs as another user's stored identity. Loss of any conjunctive authority marks the
+invocation `skipped_authorization` without launching a session. Repeated scheduled or webhook
+authorization failures pause the automation after the existing failure threshold and notify
+administrators. Provider-account and secret resolution is repeated under the current execution
+policy.
+
+New automations require an active canonical owner. Historical automations with missing or unresolved
+owners are disabled during migration and require explicit reassignment by an Administrator or Owner
+before execution.
+
+## Route Enforcement
+
+### Route metadata
+
+Authentication policy remains responsible for proving principal kind. Every route declaration also
+contains required authorization metadata. Static permission routes declare the permission beside the
+method and pattern:
+
+```ts
+authorization: requirePermission("environments.manage");
+```
+
+Session routes identify the operation applied to the already-matched path parameter. Conjunctive
+policies list every requirement explicitly:
+
+```ts
+authorization: requireAll(
+ permissionRequirement("sessions.create"),
+ permissionRequirement("sessions.collaborate")
+);
+```
+
+The router executes declared permission, session-operation, and automation checks before handlers.
+Request admission uses current authorization; a concurrent role change does not retroactively revoke
+an admitted HTTP request. Personal active-user routes, active global routes, public routes, and
+service-only callbacks each use an explicit policy kind; narrow internal callbacks name their exact
+service. `router.policy.test.ts` rejects missing metadata, duplicate method/pattern pairs,
+incompatible authentication/authorization combinations, and session requirements that reference
+absent match groups.
+
+### Exemptions
+
+Only these ingress/authentication classes bypass browser authentication:
+
+- public health;
+- browser-auth protocol endpoints;
+- externally authenticated webhook ingress;
+- image-build capability callbacks;
+- session-bound sandbox routes;
+- narrow internal service callbacks.
+
+Each exemption names its alternate ingress mechanism in route metadata. Webhook authenticity permits
+normalization/queueing only; every resulting automation or resource operation still applies the
+execution-authority policy before side effects. `user-or-service` alone is never sufficient
+authorization after this change.
+
+A generated route-to-policy inventory covers every session, child-session, attachment, media, diff,
+pull-request, credential, automation, secret, settings, and callback endpoint. Sandbox child
+operations remain parent-session-bound; human child operations use workspace session permissions.
+
+### Listing and filtering
+
+Authorization applies before list queries, with contextual automation ownership applied in SQL where
+needed.
+
+- Every user with `sessions.read` receives the workspace session list.
+- Creator and Mine filters use `sessions.user_id` as attribution, not access control.
+- Automation lists use `manage.any/read` or creator ownership as appropriate.
+- Resources requiring a missing read permission are omitted from catalogs and navigation.
+- Repository/environment catalogs require read permission; use permission is separately checked when
+ launching or configuring an execution target.
+
+## API Contracts
+
+### Current user authorization
+
+`GET /me/authorization` returns:
+
+```json
+{
+ "userId": "canonical-id",
+ "suspendedAt": null,
+ "role": { "id": "role-id", "key": "member", "name": "Member" },
+ "permissions": ["repositories.read", "sessions.create"]
+}
+```
+
+This endpoint is available only to the current browser user. Responses are private and no-store.
+
+### Role administration
+
+| Method | Path | Permission | Purpose |
+| ------ | ------------ | ---------------------- | ------------------------------------ |
+| `GET` | `/roles` | `workspace.roles.read` | List roles, counts, and permissions. |
+| `GET` | `/roles/:id` | `workspace.roles.read` | Read one role and permissions. |
+
+### Member administration
+
+| Method | Path | Permission | Purpose |
+| ------ | ------------------------- | -------------------------------------- | ------------------------------------- |
+| `GET` | `/members` | `workspace.members.read` | List canonical users and assignments. |
+| `PUT` | `/members/:userId/role` | `workspace.members.manage` or transfer | Replace one assignment. |
+| `PUT` | `/members/:userId/status` | `workspace.members.manage` | Suspend or restore access. |
+
+Owner assignment or removal requires `workspace.transfer_ownership`, including when the caller also
+has member-management permission. Suspending, deleting, or merging an Owner also requires transfer
+permission. Every role/status/delete/merge mutation uses guarded SQL that succeeds only if another
+unsuspended Owner remains in the same D1 batch. User deletion is blocked by assignment
+`ON DELETE RESTRICT`; the assignment can be removed only through this guarded membership service.
+User merge requires an explicit surviving assignment, repoints canonical session creator
+attribution, and preserves both immutable audit snapshots.
+
+Assignment and status updates apply the request-scoped authorization decision and preserve Owner
+invariants in the same D1 batch as the mutation. Authorization changes do not retroactively revoke
+an already admitted request.
+
+### Error contract
+
+Forbidden API responses use:
+
+```json
+{
+ "error": "Forbidden",
+ "code": "permission_required",
+ "permission": "environments.manage"
+}
+```
+
+Other denials use codes such as `active_user_required` and `service_capability_required`. Responses
+do not disclose another user's role.
+
+## Web Experience
+
+### Authorization state
+
+The app shell loads current authorization with the browser session. It distinguishes:
+
+- unauthenticated;
+- authenticated but suspended/unassigned;
+- authenticated and authorized;
+- authorization service unavailable.
+
+Permission checks consume the stable `hasPermission` predicate from the current-user authorization
+hook. They hide navigation that has no readable content and disable contextual controls when
+explaining the missing capability is useful. Server-rendered session pages authorize before fetching
+snapshots.
+
+### Members and roles
+
+A Workspace settings section contains:
+
+- Members: identity, provider links, status, role, last activity, and assignment actions.
+- Roles: built-in/custom roles, assignment count, and categorized permission details.
+- Audit log: actor, action, target, outcome, reason, and timestamp.
+
+The UI prevents removing the last unsuspended Owner and assigning Owner without transfer permission.
+The API repeats every invariant.
+
+### Existing navigation
+
+- Settings tabs appear only when at least one permission makes them useful.
+- New session requires `sessions.create` plus target `use` permission.
+- All/Mine becomes All/My sessions; both are filters over the workspace-wide session list.
+- Session controls reflect read, collaborate, lifecycle, delete, and sandbox-access permissions
+ independently.
+- Analytics requires `analytics.read`.
+- Automation create/manage actions are independent from automation read access.
+
+The browser never treats hidden controls or downloaded permissions as security enforcement.
+
+## Audit and Observability
+
+Durable audit events are required for:
+
+- user role assignment;
+- access suspension/restoration;
+- Owner assignment/removal;
+- secret, provider-account, commit-signing, integration, SCM, MCP, and shared-skill mutations;
+- allowed and denied member-management operations.
+
+Pure D1 mutations write the audit event in the same D1 batch.
+
+High-volume ordinary reads and successful session messages remain in structured request logs rather
+than D1 audit storage. Every authorization denial logs principal kind, actor user ID when known,
+permission, policy, resource type, opaque resource ID, reason code, request ID, and service name.
+Secret values, OAuth credentials, prompt content, and signed tokens never enter audit metadata.
+
+Metrics include denial count by permission/reason/principal, unassigned active users, assignment
+count by role, and authorization latency.
+
+## Role Changes and Revocation
+
+- HTTP requests load current assignment/status and apply changes immediately.
+- Role permission edits take effect on the next authorization lookup.
+- Browser WebSocket credentials are bound to the canonical user. Subscribe verifies current D1
+ authorization and rejects missing or suspended users, missing role assignments, and unavailable
+ authorization storage.
+- A successful subscribe asks the WebSocket manager to grant a five-minute wall-clock authorization
+ lease. The manager persists its expiry in `ws_client_mapping` and owns earliest-expiry scheduling
+ in the unified alarm. On expiry the browser clears its credential and reconnects through the
+ authorized HTTP token route.
+- Alarm and hibernation restoration close every expired connection even when it is idle. Every
+ inbound event and outbound broadcast also rejects expired leases as defense in depth. A role
+ change therefore revokes live browser access within the five-minute wall-clock lease bound.
+- Bot calls authorize on every signed HTTP request. Stale Slack/Linear issue mappings do not bypass
+ current policy.
+- Suspending a user invalidates Better Auth sessions.
+- Existing sandboxes continue running because their credentials represent the session runtime, not
+ the user. Users who lose lifecycle permission cannot reconnect or control them.
+
+## Migration and Compatibility
+
+The migration is additive and preserves current capability for every canonical user:
+
+1. Create role, permission, assignment, and audit tables.
+2. Insert protected built-in role records; their permission sets remain code-owned.
+3. Assign Administrator to every canonical user present in `users`, including identities originally
+ created through Slack, GitHub, or Linear.
+4. Create the unconditional default-role trigger. Identity provisioning after this point assigns
+ Member.
+
+No route switches to enforcement until every existing canonical user has an Administrator assignment
+and built-in role reconciliation succeeds. Administrators may continue using the application before
+Owner bootstrap. After deployment, the intended Owner signs in once to create a canonical user and
+assignment. An operator then dry-runs and executes the root CLI against that canonical ID. Sign-in
+and bot identity creation never assign Owner.
+
+Deployment documentation will state that Administrator preserves the previous installation-wide
+operational behavior, while Member becomes the default for newly admitted users.
+
+### Operator bootstrap
+
+Terraform exports the D1 database name but does not configure an Owner identity. The supported
+sequence is deploy, have the intended Owner sign in once, obtain the canonical ID from the browser
+session, run `npm run rbac:bootstrap-owner -- --database --user `, review the dry-run
+preflight, rerun with `--execute`, and verify `/health` reports `rbac.ownerAssignment=present`.
+
+When an unsuspended Owner assignment exists, `/health` reports `rbac.ownerAssignment=present`; when
+none exists, it reports `missing`. Administrators and Members can use their existing capabilities,
+but no one can exercise Owner-only actions.
+
+## Failure Handling
+
+- D1 authorization lookup failure denies the request and returns `503 authorization_unavailable`; it
+ never falls back to broad authenticated access.
+- Missing or unknown role permissions deny and emit a reconciliation error.
+- Missing user assignment denies shared application routes but permits sign-out and own identity
+ discovery so an administrator can repair access.
+- Audit-write failure aborts transactional D1 administration.
+- Web authorization metadata failure renders an unavailable state rather than the unrestricted app.
+
+## Security Invariants
+
+1. Authentication never implies authorization.
+2. Admission allowlists never imply a role beyond bootstrap/default assignment.
+3. Unknown permissions, missing assignments, suspended users, and policy errors deny access.
+4. Client-side permission checks are never authoritative.
+5. Creator and participant attribution are not authorization checks.
+6. A service cannot exceed its code-defined ceiling.
+7. An actor-backed service cannot exceed the linked user's current permissions.
+8. An actorless service can execute only exact service-only operations.
+9. Sandbox credentials remain bound to one session and confer no workspace role.
+10. Before bootstrap, no user can exercise Owner-only actions; after bootstrap, at least one
+ unsuspended Owner always exists.
+11. Only an Owner can add or remove Owner assignments.
+12. Role changes and privileged mutations produce durable, redacted audit events.
+13. Session lists require workspace read permission before returning metadata.
+14. Secret-management permission never makes stored secret values readable.
+15. External provider authorization is additional evidence, not a replacement for application RBAC.
+
+## Testing Strategy
+
+### Shared
+
+- Permission registry uniqueness and stable serialization.
+- Built-in role snapshots and persisted custom-role resolution.
+- API schema rejection of malformed role responses and assignments.
+
+### Control-plane unit
+
+- Human permission allow/deny matrix for every built-in role.
+- Custom role resolution, suspension, missing assignment, and unknown permission behavior.
+- Workspace-wide session operation permissions for every built-in role.
+- Service ceiling and actor intersection for every bot.
+- Actorless exact-endpoint service permissions.
+- Last-Owner, built-in-role, assignment, and transaction invariants.
+- Concurrent Owner demotion/suspension/delete and user-merge conflicts.
+- Stable `401`, `403`, `404`, and `503` behavior.
+- Route policy completeness requiring authorization metadata or named exemption.
+
+### Control-plane integration
+
+- Multi-user tests proving permitted Members can read, collaborate, manage lifecycle, access the
+ sandbox, and delete across workspace sessions.
+- Viewer can read but cannot prompt, launch, stop, delete, or access sandbox credentials.
+- Administrator can operate installation-wide resources but cannot transfer Owner.
+- Owner can assign roles without removing the last unsuspended Owner.
+- Secret/settings/provider-account/skill/MCP/image routes enforce individual permissions.
+- Session lists remain workspace-wide while creator and Mine filters preserve attribution semantics.
+- Role changes are enforced when idle, active, hibernated, and multi-tab WebSocket authorization
+ leases expire.
+- Suspended browser sessions and bot actors are denied.
+- D1 failure fails closed and audit failure aborts protected mutations.
+- Automation schedule, webhook, event, and manual triggers reauthorize the correct execution
+ principal after owner suspension, demotion, role edit, and target-access loss.
+- Sentry, GitHub, Slack, and Linear trigger tests assert session owner, initiator audit fields,
+ owner guard, service ceiling, actor permission intersection, and credential/profile source.
+
+### Web
+
+- Navigation and controls for Owner, Administrator, Member, Viewer, custom, suspended, and
+ unavailable states.
+- Direct URL access remains denied when navigation is hidden.
+- Session server rendering does not fetch unauthorized snapshots.
+- Workspace member controls enforce API invariants.
+- Generic forbidden responses do not trigger sign-in flows.
+
+### Bots
+
+- Each service can call only its ceiling routes.
+- Linked actor role is required for actor-backed launches and prompts.
+- Unlinked, suspended, and underprivileged actors fail closed with user-safe provider responses.
+- Existing GitHub collaborator, Slack webhook, and Linear organization checks remain enforced.
+- External session mappings cannot bypass actor role or service ceiling checks.
+
+### Migration
+
+- Empty installation assigns Member to new identities and requires an explicit canonical-ID operator
+ bootstrap for the initial Owner.
+- Existing installation assigns every pre-migration canonical user Administrator, including bot-only
+ identities, then requires the same explicit operator bootstrap.
+- Every canonical user receives exactly one assignment.
+- Built-in role reconciliation is idempotent and rejects incompatible registry drift.
+- Exact migration SQL executes under workerd/D1, including indexes and constraints.
+- Better Auth or bot identity creation followed by assignment failure cannot enter business routes
+ and retries Member assignment idempotently.
+- Owner bootstrap requires an existing unsuspended canonical user with exactly one assignment and
+ refuses another unsuspended Owner.
+- CLI bootstrap is atomic and idempotent, writes exactly one redacted operator audit event on a
+ ready transition, and writes nothing when the target is already the current Owner.
+
+## Alternatives Considered
+
+### Role column on `users`
+
+Rejected because it cannot represent custom role metadata and permission composition without
+hard-coding authorization throughout handlers.
+
+### Multiple roles per user
+
+Rejected for the initial system because role union and future deny semantics add complexity without
+a current user requirement. One assignment directly matches user-level role configuration.
+
+### Per-repository and per-environment grants
+
+Deferred because current deployment identity and repository discovery are installation-wide. Adding
+resource grants would require group semantics, environment membership rules, bot grant mapping, and
+SCM synchronization decisions not resolved by current product behavior.
+
+### Encode permissions in browser sessions
+
+Rejected because role changes would remain stale for the Better Auth session lifetime and backend
+handlers would still need authoritative policy state.
+
+### Use Session Durable Object participant roles as application RBAC
+
+Rejected because those roles exist only inside one session, are auto-created by current workflows,
+and cannot govern installation settings or repository/environment actions.
+
+### External policy engine
+
+Rejected because the initial policy consists of a small fixed permission registry plus contextual
+automation ownership. D1 and typed control-plane policy keep the trust boundary and operational
+footprint within the existing architecture.
+
+## Open Product Decisions
+
+The design chooses defaults for implementation, but product confirmation is required before
+enforcement:
+
+1. Session operations are workspace-wide when granted by the user's role.
+2. New canonical users default to Member after the RBAC migration boundary.
+3. Administrator receives all operational permissions except ownership transfer.
+4. Persisted custom roles cannot receive ownership transfer.
+5. Repository and environment access remains installation-wide rather than user-granted.
+6. Existing users are promoted to Administrator to preserve current access.
+7. Executing sandboxes continue after their creator is suspended or demoted.
+8. Authorization audit events are retained under the deployment's existing D1 retention policy.
+9. Scheduled/webhook automations stop launching when their owner loses current execution authority.
+10. Session creator and participant identities are attribution, not authorization.
+11. Five minutes is a strict wall-clock browser WebSocket revocation bound, including idle sockets.
diff --git a/public/docs/internal/2026-08-28-rbac-research.md b/public/docs/internal/2026-08-28-rbac-research.md
new file mode 100644
index 000000000..8384e78bb
--- /dev/null
+++ b/public/docs/internal/2026-08-28-rbac-research.md
@@ -0,0 +1,386 @@
+# Research: Role-Based Access Control
+
+**Date:** 2026-08-28
+
+**Status:** Superseded research snapshot
+
+**Scope:** Current identity, authentication, authorization, resources, actions, storage, user
+workflows, service integrations, and operational trust boundaries relevant to application RBAC.
+
+The implemented model is documented in [Role-Based Access Control](./2026-08-28-rbac-design.md).
+
+This document is intentionally research-only. It does not include recommendations, implementation
+plans, proposed code/API/schema changes, task breakdowns, estimates, or rollout steps.
+
+## Summary
+
+Open-Inspect authenticates human users, first-party services, and session-bound sandboxes, but it
+does not have an application role, workspace membership, permission, grant, or administrator model.
+The deployment is explicitly single-tenant: admission policy determines who may sign in, and an
+admitted human generally shares installation-wide access to repositories, sessions, environments,
+secrets, settings, provider accounts, automations, skills, MCP servers, image controls, and
+analytics.
+
+Human identity is canonicalized across GitHub, Google, Slack, and Linear. First-party bots sign
+requests as distinct services and may assert actors in their own provider namespace. Sandboxes use
+credentials bound to one session. These principal distinctions constrain authentication channels,
+but most route policies do not distinguish capabilities among admitted humans or among signed bot
+services.
+
+Sessions contain `owner` and `member` participants, but those roles are not a general authorization
+boundary. Session creator fields primarily support attribution and filtering. Existing visibility
+logic deliberately returns any session in the installation, and authenticated users or services can
+join, prompt, inspect, stop, or mutate many sessions without an owner check.
+
+The application has three broad resource scopes today: per-user preferences, session-scoped runtime
+state, and installation-wide operational resources. Repository and environment resources do not have
+application membership or grant records. External source-control permissions are consulted in some
+GitHub bot trigger paths, but ordinary web and service access uses the deployment's SCM App or token
+authority.
+
+## Research Questions
+
+1. Which identities and authentication channels exist today?
+2. Which application resources and actions would intersect with authorization decisions?
+3. Which resources are personal, session-scoped, repository/environment-scoped, or
+ installation-wide?
+4. Where are authorization decisions currently made, and what do they enforce?
+5. How do Slack, GitHub, Linear, sandboxes, and deployment operators cross trust boundaries?
+6. Which current fields represent attribution rather than ownership or access?
+7. Which gaps and unresolved product semantics affect an RBAC design?
+
+## Current Behavior
+
+### Human identity and admission
+
+- Canonical users are stored in D1 `users`; provider identities are stored in `user_identities` and
+ linked by canonical user ID.
+- Browser sign-in supports GitHub and Google through Better Auth. Browser requests reach the control
+ plane through a signed `service:web` channel and a valid browser session cookie.
+- Admission supports GitHub login, email, email domain, and GitHub organization allowlists, plus an
+ explicit unsafe allow-all mode. Admission only controls sign-in eligibility.
+- The browser session contract exposes user ID, name, email, and image. It has no role, permission,
+ membership, workspace, or resource-grant data.
+- Canonical user IDs currently scope keyboard shortcuts, managed-skill profiles, session read state,
+ temporary provider-account authorization transactions, and the session-list `Mine` filter.
+
+### Request principals and route policies
+
+The control plane resolves every authenticated request to one of:
+
+| Principal | Identity boundary | Current use |
+| ------------------- | ----------------------------------------- | -------------------------------------------------- |
+| Human user | Canonical user ID | Browser-originated application requests |
+| First-party service | Service name plus optional asserted actor | Web, Slack, GitHub, and Linear Workers |
+| Sandbox | Session ID | Session runtime callbacks and credential brokerage |
+
+Route authentication distinguishes public, handler-authenticated, web-service, human-user,
+user-or-service, sandbox, and sandbox-fallback requests. It does not express application actions,
+resource scopes, user roles, or grants. Human-only routes exclude bots but admit every authenticated
+human. Most `user-or-service` routes admit every signed first-party service, not a named subset.
+
+### Session visibility and participation
+
+- Session creation stores a canonical creator in the D1 session index and creates a Durable Object
+ participant with role `owner`.
+- Other identities are added as `member` participants when they request a WebSocket token or send a
+ prompt.
+- `SessionIndexStore.getVisibleForUser()` deliberately ignores the supplied user ID and returns any
+ existing session. Its source comment names this the single-tenant visibility boundary.
+- Session lists are global unless `createdBy=me` is supplied as an explicit filter.
+- Session title, archive, and unarchive handlers require participation, but do not distinguish
+ `owner` from `member`. Other lifecycle and runtime routes do not consistently require existing
+ participation.
+- An authenticated user or asserted service actor can request a WebSocket token for a session and be
+ added as a member. Prompt submission follows the same auto-membership pattern.
+- Deletion, stop, event, artifact, media, attachment, participant, pull-request, and other session
+ operations generally rely on route authentication and a supplied session ID rather than creator or
+ participant ownership.
+- Sandbox credentials are verified against the Session Durable Object and cannot authenticate to a
+ different session. Child-sandbox fallbacks are also bound to their parent session.
+
+### Installation-wide resources
+
+The following resources are shared across admitted users in the current deployment model:
+
+| Resource | Read actions | Mutation or execution actions |
+| ------------------------ | ------------------------------------------- | ------------------------------------------------------------ |
+| Repository catalog | List repositories, branches, metadata | Use as session/environment/automation targets |
+| Global secrets | List key metadata | Create/update/delete values |
+| Repository secrets | List key metadata | Create/update/delete values |
+| Environments | List/view | Create/update/delete; manage repositories and branches |
+| Environment secrets | List key metadata | Create/update/delete/import values |
+| Integration settings | View global/repository/environment settings | Enable, update, override, reset |
+| SCM and sandbox settings | View configuration | Update/reset defaults and overrides |
+| Model preferences | View enabled models | Change installation-wide model visibility |
+| Provider accounts | List/status | Connect, reconnect, rename, verify, enable, disable, default |
+| Automations | List/view runs | Create, edit, trigger, pause, resume, delete, rotate key |
+| Managed shared skills | List/view | Import, edit, assign, reimport, delete |
+| MCP servers | List/view | Create, edit, delete commands, headers, and environment |
+| Image builds | View status/feed | Toggle prebuilds, trigger builds |
+| Commit signing | View metadata | Configure/update/delete signing material |
+| Analytics | View installation aggregates | No primary mutation workflow |
+
+Environments have no owner, member, team, role, or ACL columns. Repository access is based on the
+deployment's SCM App installation or configured token. Generic settings and secret stores are not
+keyed by user. Provider-account creator/updater IDs and automation creator fields record attribution
+but do not restrict later access.
+
+### Personal and local resources
+
+- Keyboard shortcut preferences are stored by canonical user ID.
+- Managed-skill profiles are associated with a canonical user, while the shared skill catalog is
+ installation-wide.
+- Session read states are stored by `(user_id, session_id)` but rely on the broad session visibility
+ boundary.
+- Provider-account device-authorization transactions are user-scoped while in progress; completed
+ provider accounts are installation-wide.
+- Appearance and syntax preferences are browser-local.
+- Slack and Linear bot preferences are provider-user-scoped in their Workers' KV stores.
+
+### Web application behavior
+
+- `AppAuthBoundary` gates the application shell on authentication state only.
+- The sidebar exposes new session, all/mine sessions, settings, automations, analytics, and archived
+ sessions to every authenticated user.
+- Settings navigation is identical for all authenticated users except for deployment-capability
+ checks such as repository-image support.
+- Session controls react to lifecycle, connection, and loading state, not participant role.
+- No client condition was found for an administrator flag, role, permission list, repository grant,
+ environment membership, session owner role, or creator equality.
+- The client does not currently represent an authenticated-but-forbidden state distinct from sign-in
+ admission denial, aside from generic API errors.
+
+## Relevant Workflows
+
+### Browser request
+
+1. GitHub or Google OAuth establishes a Better Auth browser session.
+2. The Next.js server signs the control-plane request as `service:web` and forwards the browser
+ cookie.
+3. The control plane verifies both channel and browser identity and creates a user principal.
+4. The route policy checks principal kind and SCM compatibility.
+5. The handler reads or mutates the requested resource; most handlers have no additional user-level
+ access check.
+
+### Bot-created session
+
+1. A bot verifies an external Slack, GitHub, or Linear webhook.
+2. The bot signs a control-plane request with its per-service secret and may assert the external
+ actor in its namespace.
+3. The control plane verifies the service and actor namespace, resolves or creates a canonical user,
+ and derives session identity from the principal.
+4. Session creation requires an actor-backed participant. Existing-session prompts may be actorless
+ and are then attributed to `anonymous`.
+5. The selected repository or environment is resolved using deployment-wide catalogs and
+ credentials. GitHub trigger flows additionally enforce configured allowlists or GitHub
+ write-level collaborator permissions; Slack and Linear do not perform equivalent SCM-user checks.
+
+### Session collaboration
+
+1. A browser or bot addresses a session by ID.
+2. A WebSocket-token or prompt request can create a `member` participant automatically.
+3. The Session Durable Object stores participants, messages, artifacts, diffs, repositories, sandbox
+ state, and credentials.
+4. Participant role is returned in shared session types, but the web does not consume it as an
+ authorization signal.
+
+### Sandbox runtime
+
+1. The control plane creates and hashes a per-session sandbox token.
+2. The token and session configuration are injected into the sandbox.
+3. Sandbox requests are authenticated against the session ID in the route.
+4. Session-bound routes broker SCM credentials, provider access, commit signing, skills,
+ attachments, and runtime events.
+5. The sandbox is not represented as a human role and cannot authenticate outside its bound session
+ through the sandbox credential.
+
+### Deployment and data plane
+
+1. GitHub Actions and Terraform provision Cloudflare, D1, R2, Workers, service secrets, and Modal.
+2. Deployment operators hold authority outside the application's principal model through source
+ control, GitHub environments, Cloudflare, Terraform state, Modal, and SCM App installation
+ access.
+3. The control plane authenticates to Modal with a deployment-wide HMAC secret.
+4. Modal trusts possession of that secret for authenticated endpoints and does not receive the
+ initiating application user, role, or resource grants.
+
+## Existing Patterns
+
+### Central authentication composition
+
+The router attaches a verified principal before authenticated handlers run. Route definitions carry
+typed authentication policy, and policy-completeness tests assert that every route declares one.
+
+### Canonical cross-provider identity
+
+Browser and bot identities converge on a canonical D1 user while retaining provider identity and
+participant identity. Body-supplied identity and credential fields are rejected for
+identity-sensitive routes.
+
+### Session-bound capabilities
+
+Sandbox tokens, image-build callback tokens, and browser participant WebSocket tokens are scoped to
+specific runtime resources rather than functioning as installation-wide human credentials.
+
+### Provider and scope registries
+
+Repositories use shared identity helpers, environments have opaque IDs and ordered repository
+membership, image builds use explicit repository/environment scope kinds, and integration settings
+already resolve global, repository, and environment levels.
+
+### Attribution without authorization
+
+Sessions, automations, provider accounts, skills, and logs record creators or actors. Existing code
+and design documents explicitly distinguish these fields from ownership checks.
+
+### Denial and audit behavior
+
+Authentication failures use `401`; principal-kind failures use `403`. Some sensitive workflows,
+including managed skills and Slack notification, emit structured audit logs. There is no complete,
+durable application authorization audit ledger.
+
+## Constraints and Invariants
+
+- TypeScript and Python use milliseconds and seconds respectively for durations.
+- Shared contracts are consumed by control plane, web, and bot packages and are built first.
+- D1 is the installation-wide relational store; each Session Durable Object has separate SQLite
+ state and is not directly joinable with D1 during an in-object operation.
+- Route authentication happens before handler execution; handler-authenticated webhooks apply their
+ own provider or capability checks.
+- Browser requests must retain both a signed web-service channel and a valid browser session.
+- Bot actors can only be asserted by their owning first-party service namespace.
+- Caller-supplied identity fields are rejected where verified principal identity is required.
+- Sandbox credentials remain session-bound and session provider-auth choices are immutable after
+ creation.
+- Repository owners may contain nested path segments; repository identity helpers split on the last
+ slash and preserve the complete owner.
+- Environment sessions snapshot repository membership; later environment changes do not alter
+ existing sessions.
+- Secrets are encrypted at rest and values are not returned by list operations, but authorization to
+ manage their ciphertext and metadata is installation-wide.
+- The Modal API receives a deployment credential, not end-user identity; application authorization
+ currently terminates at the control plane.
+- Existing admitted users have broad access under documented single-tenant semantics.
+
+## Known Gaps and Risks
+
+- No role, membership, grant, group, workspace, or administrator records exist in D1.
+- No authorization action vocabulary or resource-scope vocabulary exists in shared contracts.
+- Route policies conflate authentication channel, principal kind, SCM support, and broad route
+ access; handlers apply resource checks inconsistently.
+- `GITHUB_USER_OR_SERVICE_ROUTE` and similar policies often admit all signed services despite their
+ names.
+- Session `owner/member` roles do not define owner-exclusive actions and do not govern most access.
+- Session creator, provider-account creator, automation creator, and updater fields can be mistaken
+ for authorization ownership despite current attribution-only behavior.
+- The repository catalog reflects installation authority rather than authenticated-user grants.
+- A repository can belong to multiple environments, and environments can contain multiple
+ repositories; current data has no rules for combining access at those boundaries.
+- Bots differ in external authorization evidence. GitHub has repository permission checks in trigger
+ flows, while Slack and Linear rely primarily on webhook authenticity, configured mappings, and
+ deployment catalogs.
+- Service credentials provide broad route-family capabilities and are not generally constrained by
+ actor, creator, repository, or session.
+- The web exposes navigation and controls before knowing whether an action could be forbidden.
+- There is no complete durable record of allow/deny decisions, policy changes, role assignment, or
+ access revocation.
+- Existing tests primarily distinguish authenticated from unauthenticated requests, not multiple
+ human capability levels or cross-user denial.
+- Long-lived sessions, WebSockets, bot mappings, and sandboxes can outlast changes to human access;
+ current code has no access-revocation lifecycle because access grants do not exist.
+- External operator authority is outside the application and cannot be represented by current
+ principals.
+
+## Open Questions
+
+1. Does one Open-Inspect installation correspond permanently to one workspace, or can an
+ installation contain multiple independently administered organizations?
+2. Are application roles intended to be fixed built-in roles, configurable custom roles, or both?
+3. Which role bootstraps the first deployment administrator, and how is loss of all administrators
+ recovered?
+4. Are repository permissions inherited solely from an application role, assigned per user/group,
+ synchronized from SCM, or combined from those sources?
+5. Are environments independent authorization resources or derived from access to all, any, or the
+ primary member repository?
+6. Are sessions private to creators by default, visible to users with target access, or visible to
+ the whole workspace?
+7. Which session actions differ among creator, participant owner, participant member, repository
+ maintainer, and workspace administrator?
+8. Does adding a participant grant access, or merely record collaboration after another policy has
+ admitted access?
+9. Do automation runs and child sessions inherit access from the automation owner, triggering actor,
+ target resource, parent session, or a service identity?
+10. Which first-party services may read or mutate installation settings, secrets, provider accounts,
+ and arbitrary sessions?
+11. Do bots act with service-owned capabilities, the asserted human actor's capabilities, or an
+ intersection of both under the intended product semantics?
+12. How are actors without a linked canonical user handled when authorization requires user-level
+ grants?
+13. Is viewing secret key metadata distinct from writing or deleting secret values?
+14. Are analytics, user directories, audit records, and usage/cost data separate administrative
+ capabilities?
+15. Which role and grant changes must revoke active WebSockets, bot thread mappings, sandbox access,
+ or in-flight provider authorization transactions?
+16. Which authorization changes require historical audit retention, and for how long?
+17. Must existing admitted users preserve their current broad access when role records first appear?
+18. Are deployment operators expected to be application administrators, or are these intentionally
+ separate authority domains?
+
+## Evidence
+
+- `packages/control-plane/src/auth/principal.ts`: defines user, service, and sandbox principals and
+ service actor-namespace rights.
+- `packages/control-plane/src/auth/authenticate.ts`: composes signed web-service and browser-session
+ authentication.
+- `packages/control-plane/src/auth/identity-enforcement.ts`: derives actor identity and rejects
+ caller-supplied identity fields.
+- `packages/control-plane/src/auth/user/admission-policy.ts`: defines sign-in admission rules.
+- `packages/control-plane/src/db/user-store.ts`: canonicalizes provider identities into users.
+- `packages/control-plane/src/routes/shared.ts`: defines route authentication and SCM policies.
+- `packages/control-plane/src/router.ts`: attaches principals and enforces principal-kind policies.
+- `packages/control-plane/src/db/session-index.ts`: implements installation-wide session visibility.
+- `packages/control-plane/src/routes/session-index.ts`: lists and deletes sessions and stores
+ per-user read state.
+- `packages/control-plane/src/routes/session-runtime-proxy.ts`: exposes session runtime actions.
+- `packages/control-plane/src/routes/session-ws-token.ts`: mints participant WebSocket credentials.
+- `packages/control-plane/src/routes/session-prompt.ts`: derives prompt authors and allows automatic
+ session participation.
+- `packages/control-plane/src/session/schema.ts`: stores Session Durable Object participants and
+ runtime state.
+- `packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts`: checks
+ participation for selected lifecycle mutations.
+- `packages/shared/src/types/sessions.ts`: defines `owner/member` participant roles.
+- `packages/web/src/lib/browser-auth-session-contract.ts`: exposes browser user identity without
+ authorization data.
+- `packages/web/src/components/app-auth-boundary.tsx`: gates the application on authentication.
+- `packages/web/src/components/session-sidebar.tsx`: exposes shared navigation and All/Mine filters.
+- `packages/web/src/components/settings/settings-nav.tsx`: exposes installation settings without
+ user-role filtering.
+- `packages/control-plane/src/routes/repos.ts`: lists repositories using deployment SCM authority.
+- `packages/control-plane/src/routes/environments.ts`: exposes installation-wide environment CRUD.
+- `packages/control-plane/src/routes/secrets.ts`: exposes global and repository secret management.
+- `packages/control-plane/src/routes/environment-secrets.ts`: exposes environment secret management.
+- `packages/control-plane/src/routes/integration-settings.ts`: manages global, repository, and
+ environment settings.
+- `packages/control-plane/src/routes/model-provider-accounts.ts`: manages installation-wide provider
+ accounts with human-only authentication.
+- `packages/control-plane/src/routes/automations.ts`: exposes shared automation lifecycle actions.
+- `packages/control-plane/src/routes/skills.ts`: separates shared skill administration from per-user
+ profiles.
+- `packages/control-plane/src/routes/mcp-servers.ts`: exposes shared MCP server management.
+- `packages/control-plane/src/routes/analytics.ts`: exposes installation-wide analytics.
+- `terraform/d1/migrations/0019_create_users.sql`: creates canonical users and attribution columns.
+- `terraform/d1/migrations/0033_environments.sql`: creates environments without ownership or grants.
+- `terraform/d1/migrations/0055_session_read_states.sql`: creates per-user session read state.
+- `docs/HOW_IT_WORKS.md`: documents the single-tenant security and repository-access model.
+- `provider-accounts.md`: explicitly treats creator/updater fields as audit metadata and provider
+ accounts as installation-wide.
+- `packages/slack-bot/src/sessions/control-plane-client.ts`: sends signed Slack actor session calls.
+- `packages/github-bot/src/handlers.ts`: applies GitHub trigger and sender authorization checks.
+- `packages/linear-bot/src/webhook-handler.ts`: resolves Linear actors and session targets.
+- `packages/control-plane/src/sandbox/client.ts`: authenticates deployment-wide control-plane calls
+ to Modal.
+- `packages/control-plane/src/router.policy.test.ts`: checks route authentication policy coverage.
+- `packages/control-plane/test/integration/ws-token-participants.test.ts`: verifies automatic member
+ creation.
diff --git a/public/docs/internal/2026-08-30-session-access-research.md b/public/docs/internal/2026-08-30-session-access-research.md
new file mode 100644
index 000000000..7249aca01
--- /dev/null
+++ b/public/docs/internal/2026-08-30-session-access-research.md
@@ -0,0 +1,407 @@
+# Research: Session Access and Contribution
+
+**Date:** 2026-08-30 **Status:** Superseded current-state snapshot **Scope:** Session permission,
+relationship, participant, listing, and WebSocket behavior before workspace-wide session
+authorization was adopted.
+
+This document is intentionally research-only. It does not include recommendations, implementation
+plans, proposed code/API/schema changes, task breakdowns, estimates, or rollout steps.
+
+The accepted replacement is
+[Workspace-Wide Session Authorization](./2026-08-30-workspace-wide-session-authorization-design.md).
+
+## Summary
+
+The current system does not generally require a user to be a session creator or participant before
+they can read or contribute to a session. Built-in Members receive `sessions.read.any` and
+`sessions.collaborate.any`; Viewers receive `sessions.read.any`. These `any` permissions bypass the
+`session_access` relationship table entirely. An unrelated Member can therefore list, read, prompt,
+upload collaborative artifacts, and request a WebSocket token for any workspace session.
+
+`session_access` remains active in narrower workflows. It gates Member lifecycle and sandbox access,
+requires creator status for Member deletion and participant management, supports custom roles that
+hold only `.own` permissions, filters own-scoped lists, and constrains every actor-backed bot call
+because service actors are forced to `own` scope. WebSocket subscription also consults it when the
+user's collaboration permission resolves to `.own`.
+
+The system also has a separate Session Durable Object `participants` table. It stores session-local
+identity, SCM metadata, WebSocket tokens, presence identity, and an `owner` or `member` role. It is
+not the authority used by `requireSession`, but title, archive, and unarchive still require the
+caller to exist in that table. D1 relationships and Durable Object participants can therefore
+diverge and have different effects.
+
+The resulting complexity represents several different concerns under similar terminology rather than
+one uniform contribution boundary.
+
+## Research Questions
+
+1. Does session access currently restrict who can read or contribute to a session?
+2. Which operations still depend on creator or participant relationships?
+3. What does `requireSession` enforce for humans, services, and sandboxes?
+4. How do D1 `session_access` and Durable Object participants differ?
+5. Which current behaviors and documents are inconsistent or ambiguous?
+
+## Current Behavior
+
+### Built-in role behavior
+
+The built-in role registry gives Members these session permissions:
+
+- `sessions.read.any`
+- `sessions.collaborate.any`
+- `sessions.lifecycle.own`
+- `sessions.participants.manage.own`
+- `sessions.delete.own`
+- `sessions.sandbox_access.own`
+
+Viewers receive `sessions.read.any` and no contribution or lifecycle permission. Administrators and
+Owners receive the `any` form of every session operation.
+
+`resolveScopedPermission()` selects `any` before `own`. The router does not query a session
+relationship after resolving `any`.
+
+Consequences for a built-in Member:
+
+| Operation | Existing relationship required? | Current basis |
+| ------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------- |
+| List sessions | No | `sessions.read.any` |
+| Read session state, messages, artifacts, media, diffs, and children | No | `sessions.read.any` |
+| Submit an HTTP prompt | No | `sessions.collaborate.any` |
+| Request a WebSocket token | No | `sessions.collaborate.any` |
+| Upload attachments, media, or diffs | No | `sessions.collaborate.any` |
+| Create a pull request or child session | No prior relationship | `sessions.collaborate.any`, plus operation-specific requirements |
+| Stop, rename, archive, unarchive, refresh, or retry | Yes | `sessions.lifecycle.own` |
+| Obtain sandbox credentials | Yes | `sessions.sandbox_access.own` |
+| Delete a session | Creator only | `sessions.delete.own` |
+| Manage participants | Creator only | `sessions.participants.manage.own` |
+
+An Administrator or Owner bypasses these relationship requirements through the corresponding `*.any`
+permission at the router layer.
+
+### Operation-to-relationship mapping
+
+`session-authorization-policy.ts` maps each operation to both a permission stem and an own-scope
+relationship:
+
+| Operation | Permission stem | Relationship under `.own` |
+| ---------------------- | ------------------------------ | ------------------------- |
+| Read | `sessions.read` | Creator or participant |
+| Collaborate | `sessions.collaborate` | Creator or participant |
+| Lifecycle | `sessions.lifecycle` | Creator or participant |
+| Participant management | `sessions.participants.manage` | Creator |
+| Sandbox access | `sessions.sandbox_access` | Creator or participant |
+| Delete | `sessions.delete` | Creator |
+
+The term `own` therefore has two meanings in current policy. For four operations it means any access
+relationship; for deletion and participant management it means creator.
+
+### `requireSession`
+
+`requireSession(operation, sessionIdParam)` creates an active-user route policy with one session
+requirement. At request admission, the router:
+
+1. Loads the effective authorization for the human user or represented service actor.
+2. Rejects suspended users and missing role assignments.
+3. Resolves the operation's `any` or `own` permission.
+4. Applies the signed service's capability ceiling.
+5. Forces signed service actors to `own` scope.
+6. Queries `session_access` only when the resulting scope is `own`.
+
+Relationship failures return `session_access_required` or `creator_required` with HTTP 403.
+Unexpected authorization storage failures return `authorization_unavailable` with HTTP 503.
+
+For sandbox-fallback routes, `requireSession` describes the user/service path. A verified sandbox
+principal does not have a workspace user authorization and bypasses these RBAC requirements. Its
+authority comes from the sandbox token being bound to the route's session ID.
+
+### D1 `session_access`
+
+Migration 0071 defines one canonical relationship per session and workspace user:
+
+```sql
+CREATE TABLE session_access (
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ relation TEXT NOT NULL CHECK (relation IN ('creator', 'participant')),
+ PRIMARY KEY (session_id, user_id)
+);
+```
+
+The table contains no activity state, timestamps, invitation source, participant identifier, or
+WebSocket state.
+
+Creator rows are inserted with the D1 session index. Migration 0071 backfills canonical historical
+creators. Participant rows are inserted after:
+
+- successful public WebSocket-token issuance;
+- successful public participant addition.
+
+Participant activation uses `ON CONFLICT DO NOTHING`, so an existing creator row is never downgraded
+to participant.
+
+There is no production participant-removal route or D1 deactivation helper. Relationship deletion
+currently occurs through session/user cascade, user merge, test setup, or direct database activity.
+
+### Session Durable Object participants
+
+The Session Durable Object has a separate `participants` table containing:
+
+- a session-local participant ID;
+- a provider/session-local `user_id`;
+- an optional canonical D1 `canonical_user_id`;
+- SCM identity and credentials;
+- `owner` or `member` role;
+- WebSocket token hash and issuance time;
+- join time.
+
+Session initialization creates an owner participant. WebSocket-token issuance creates or enriches a
+member participant. API prompt enqueue also creates a missing participant.
+
+The DO `owner` or `member` value is not read by `requireSession`. Canonical creator authority comes
+from D1 `session_access.relation = 'creator'`. The DO role is returned in participant responses and
+persists as session-local state.
+
+Title, archive, and unarchive differ from other lifecycle routes: after router authorization, their
+DO handlers also require the acting identity to exist in the local participants table. Stop, pull
+request refresh, diff retry, and child cancellation do not share that second participant-existence
+check.
+
+### Contribution paths
+
+HTTP prompt admission uses `requireSession("collaborate")`. For a built-in Member this resolves to
+`collaborate.any`, so no relationship is required. The DO creates a participant when the prompt
+author is not already present, but this prompt path does not create a D1 `session_access` row.
+
+WebSocket-token issuance also uses `collaborate`. A successful token response creates both a DO
+participant and a D1 participant relationship. This means the common browser join flow establishes
+the relationship after open collaboration has already authorized the join.
+
+Once a browser WebSocket subscribes successfully, prompt, cancel, stop, history, typing, and
+presence messages use the authenticated client and its authorization lease. Individual WebSocket
+commands do not independently resolve read, collaborate, or lifecycle permissions.
+
+### WebSocket authorization
+
+The initial WebSocket upgrade verifies only that the session exists. The socket remains
+unauthenticated until it sends a subscription token.
+
+Subscription verifies:
+
+- the token hash maps to a DO participant;
+- the participant has a canonical user ID;
+- the canonical user is active and assigned;
+- current `sessions.collaborate` permission;
+- D1 access when collaboration scope is `.own`;
+- the 24-hour token lifetime.
+
+A successful subscription receives a five-minute authorization lease. During that lease, permission
+and relationship changes are not continuously queried. Expiry closes the socket and a later
+subscription evaluates current authorization again.
+
+For the built-in Member's `collaborate.any`, subscription does not require the D1 relationship. For
+custom roles with only `collaborate.own`, removing the relationship causes a later subscription to
+fail.
+
+### Lists and displayed capabilities
+
+Session list and inbox SQL use `sessionAccessPredicate()` only when read scope is `own`. For scope
+`any`, the predicate is `1 = 1`.
+
+Because Member and Viewer use `read.any`, their ordinary lists are workspace-wide. The `Mine` filter
+is separate: it filters `sessions.user_id`, which is creator attribution rather than an
+authorization relationship.
+
+At the time of this research, lists also computed `canManageLifecycle` from the caller's lifecycle
+scope and relationship. The workspace-wide authorization implementation later removed that response
+field; the web client now derives lifecycle-control visibility from current-user permissions, while
+lifecycle endpoints perform their own request admission.
+
+### Services and bots
+
+Signed services use the represented canonical actor's role, a hard-coded service capability ceiling,
+and a forced `own` session scope. A bot actor therefore needs a D1 creator or participant
+relationship even when that actor's built-in Member role contains `read.any` and `collaborate.any`.
+
+This produces a contribution boundary for bot actors that does not exist for browser Members. An
+unrelated Slack actor is denied when prompting another actor's session with
+`session_access_required`.
+
+No session route currently declares an actorless service grant. Several bot call sites issue
+actorless session requests, including Slack attachment/media operations and Linear stop/event
+operations. Central route admission rejects such requests with `service_actor_required` before
+session relationship evaluation.
+
+### Child sessions
+
+User/service child creation requires `sessions.create` and collaboration on the parent. A parent
+sandbox token can create a child through the sandbox capability path without user RBAC.
+
+The child creator is the parent session's active prompt author. Parent access does not automatically
+create child access for a different parent creator. User/service child read and cancellation are
+authorized against the child, while the parent sandbox path authenticates against the parent and
+then checks parent-child lineage in the handler.
+
+## Relevant Workflows
+
+### Browser Member joins an unrelated session
+
+1. Session list is visible through `sessions.read.any`.
+2. Session read is admitted without `session_access`.
+3. WebSocket-token request is admitted through `sessions.collaborate.any`.
+4. The DO creates or updates a participant and rotates its token.
+5. The control plane inserts D1 participant access.
+6. Subscription rechecks collaboration and grants a five-minute lease.
+7. The participant relationship now satisfies Member lifecycle-own and sandbox-access-own.
+
+### HTTP prompt without WebSocket token
+
+1. Prompt request is admitted through `sessions.collaborate.any` for a Member.
+2. The DO creates a missing participant and enqueues the prompt.
+3. No D1 participant relationship is created by this path.
+4. Later lifecycle-own or sandbox-access-own checks still depend on another path having created D1
+ access.
+
+### Actor-backed bot contribution
+
+1. The service signature identifies the service and represented actor.
+2. The actor's current workspace authorization is loaded.
+3. The service ceiling is applied.
+4. Session scope is forced to `own`.
+5. The actor must already have creator or participant D1 access.
+
+### Administrator lifecycle request without joining
+
+1. `sessions.lifecycle.any` passes router admission without D1 access.
+2. Stop, refresh, and retry can proceed without a DO participant check.
+3. Title, archive, and unarchive query the DO participant table and return 403 when the identity is
+ absent.
+
+## Existing Patterns
+
+- Workspace permissions and session relationships are evaluated in the control-plane router.
+- The D1 relationship projection uses canonical workspace user IDs.
+- The Session DO participant table owns session-local attribution, SCM metadata, tokens, and
+ connection identity.
+- Open collaboration is expressed by built-in `*.any` permissions rather than an exception inside
+ relationship code.
+- Service actors are intentionally narrowed to `own` regardless of their human role's `any` grant.
+- Sandbox principals use possession of a session-bound capability instead of workspace RBAC.
+- WebSocket authorization is evaluated at subscription and represented by a bounded lease.
+- Session list authorization and lifecycle capability are calculated in SQL before results are
+ returned.
+
+## Constraints and Invariants
+
+- One canonical user has at most one D1 relationship per session.
+- Creator access is not replaced by participant activation.
+- Own-scoped deletion and participant management require creator relation.
+- Other own-scoped operations accept creator or participant relation.
+- Any-scoped operations do not consult `session_access`.
+- Actor-backed services cannot use any-scoped session access.
+- A sandbox token is valid only for its bound session route.
+- Successful WebSocket subscription requires a canonical user identity.
+- WebSocket authorization is bounded by a five-minute lease and token use by a 24-hour lifetime.
+- D1 and Session DO writes do not share a cross-store transaction.
+- User merge preserves the strongest D1 relationship when creator and participant rows collide.
+
+## Known Gaps and Risks
+
+### Relationship and participant divergence
+
+The two stores have different writers and no reconciliation workflow:
+
+- API prompt creates a DO participant without D1 access.
+- DO success followed by D1 activation failure leaves a DO participant without D1 access.
+- D1 user merge rewrites access but does not update existing DO canonical participant identities.
+- There is no participant-removal flow spanning D1, DO tokens, presence, or existing sockets.
+- DO `owner/member` and D1 `creator/participant` can disagree.
+
+### Inconsistent lifecycle enforcement
+
+Title, archive, and unarchive require local DO participant existence after router authorization.
+Other lifecycle endpoints do not. This makes `sessions.lifecycle.any` behavior dependent on the
+specific endpoint and whether the caller previously joined the session.
+
+### Contribution does not uniformly establish access
+
+WebSocket-token contribution establishes D1 participant access; direct HTTP prompting does not. Both
+can establish a DO participant.
+
+### Service-call mismatches
+
+Some bot call sites omit actors for routes whose central policy requires one. Package-local tests
+mock the control plane and do not cover these calls through real central authorization.
+
+### Documentation drift
+
+The RBAC design includes mutually inconsistent statements about Member visibility. Its role matrix
+describes open Member read/collaboration, while other sections describe Member lists as
+creator/participant filtered. It also documents participant removal that is not implemented and
+states that the DO has no local owner role even though that field remains in schema and runtime
+behavior.
+
+### Test coverage boundaries
+
+Existing tests cover scoped permission resolution, relationship checks, list filtering, WebSocket
+subscription, service actor isolation, creator-only deletion, and projection writes. No
+comprehensive role-by-operation HTTP matrix or end-to-end test of active WebSocket authorization
+changes across a lease boundary was found.
+
+## Open Questions
+
+1. Is `session_access` intended to represent durable membership, a capability projection, or only
+ the relationship input for `.own` permissions?
+2. Is open Member contribution intended to establish membership, or is the relationship created by
+ WebSocket-token issuance incidental to the current browser workflow?
+3. Is direct HTTP prompt participation intentionally excluded from D1 participant activation?
+4. Are the DO participant checks on title, archive, and unarchive intentional authorization or
+ residual pre-RBAC behavior?
+5. Does actor-backed service isolation intentionally differ from open browser Member collaboration?
+6. Are DO `owner/member` roles still part of supported session semantics, or only retained state for
+ compatibility and presentation?
+7. Was participant removal deliberately excluded from the current product surface?
+8. Is parent-to-child access intentionally independent when the active prompt author differs from
+ the parent creator?
+9. Are the RBAC design documents historical artifacts, living documentation, or a mixture of both?
+
+## Evidence
+
+- `packages/shared/src/rbac.ts`: built-in role permission sets and any-before-own scope resolution.
+- `packages/control-plane/src/authorization/session-authorization-policy.ts`:
+ operation-to-permission and operation-to-relationship mapping.
+- `packages/control-plane/src/routes/shared.ts`: `requireSession` route metadata construction.
+- `packages/control-plane/src/router.ts`: active-user, service-ceiling, scoped-permission, and
+ relationship enforcement.
+- `packages/control-plane/src/db/session-access.ts`: list predicate, exact relationship check, and
+ participant activation.
+- `terraform/d1/migrations/0071_rbac_foundation.sql`: relationship schema, index, and creator
+ backfill.
+- `packages/control-plane/src/db/session-index.ts`: creator insertion, own-scoped listing, and
+ lifecycle capability projection.
+- `packages/control-plane/src/db/session-inbox-store.ts`: inbox visibility and lifecycle capability.
+- `packages/control-plane/src/routes/session-ws-token.ts`: public token issuance and D1 participant
+ activation.
+- `packages/control-plane/src/routes/session-prompt.ts`: collaboration admission and
+ principal-derived prompt identity.
+- `packages/control-plane/src/session/message-queue.ts`: prompt-created DO participants.
+- `packages/control-plane/src/session/schema.ts`: DO participant schema and owner/member role.
+- `packages/control-plane/src/session/connection-authenticator.ts`: WebSocket token, canonical user,
+ authorization, and token-age checks.
+- `packages/control-plane/src/session/websocket-manager.ts`: lease persistence, lookup, and expiry.
+- `packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts`: residual DO
+ participant checks for title/archive/unarchive.
+- `packages/control-plane/src/authorization/service-permissions.ts`: bot service capability
+ ceilings.
+- `packages/control-plane/test/integration/rbac-routes.test.ts`: open Member lists and creator-only
+ deletion.
+- `packages/control-plane/test/integration/websocket-client.test.ts`: any/own collaboration,
+ relationship loss, suspension, and assignment failure behavior.
+- `packages/control-plane/test/integration/service-auth.test.ts`: actor-backed service relationship
+ isolation.
+- `packages/control-plane/test/integration/d1-session-index.test.ts`: creator projection, missing
+ projection, and lifecycle capability behavior.
+- `packages/control-plane/test/integration/user-merge.test.ts`: relationship collision precedence.
+- `public/docs/internal/2026-08-28-rbac-design.md`: stated RBAC model and observed documentation
+ contradictions.
+- Git commit `69d32c6`: changed Member read and collaboration from own to any while retaining the
+ relationship projection for narrower operations.
diff --git a/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md b/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md
new file mode 100644
index 000000000..5934a94d1
--- /dev/null
+++ b/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md
@@ -0,0 +1,193 @@
+# Design: Workspace-Wide Session Authorization
+
+**Date:** 2026-08-30
+
+**Status:** Accepted
+
+**Research:** [2026-08-30-session-access-research.md](./2026-08-30-session-access-research.md)
+
+## Summary
+
+Open-Inspect sessions are workspace-wide resources. An active user may perform an operation on every
+session when their workspace role grants that operation. Session creator and participant
+relationships do not grant, narrow, or revoke authorization.
+
+Session authorization uses unscoped operation permissions. Actor-backed bot requests intersect the
+represented user's current role with the bot service's fixed capability ceiling, without applying a
+session relationship check.
+
+Creator attribution, participant identity, sandbox capability binding, and WebSocket authorization
+remain supported concerns, but none is a session access-control list.
+
+## Context
+
+Before workspace RBAC, authenticated users could operate across sessions without a creator or
+participant authorization boundary. The RBAC foundation introduced `.own` and `.any` session
+permission pairs and a D1 `session_access` projection. Built-in Members still received
+workspace-wide read and collaboration, while lifecycle, sandbox access, deletion, participant
+management, and bot requests became relationship-dependent.
+
+That partial relationship model does not match the product's multiplayer behavior. It also creates
+two inconsistent participant stores: D1 relationships used for authorization and Session Durable
+Object participants used for message identity, presence, SCM metadata, and WebSocket tokens.
+Different contribution paths update those stores differently.
+
+## Decisions
+
+### Workspace-wide operations
+
+Session permissions are operation permissions without resource scope:
+
+- `sessions.read`
+- `sessions.collaborate`
+- `sessions.create`
+- `sessions.lifecycle`
+- `sessions.sandbox_access`
+- `sessions.delete`
+
+A granted session operation applies to every session in the workspace. No route or WebSocket
+authorization check consults creator or participant relationships.
+
+Deletion is workspace-scoped. Creator-only deletion is explicitly deferred and is not part of this
+RBAC change.
+
+### Built-in roles
+
+Built-in roles distinguish which operations a user may perform, not which sessions they may target:
+
+| Role | Session behavior |
+| ------------- | ----------------------------------------------------------------------------------- |
+| Owner | Every session operation across the workspace. |
+| Administrator | Every session operation across the workspace. |
+| Member | Create, read, collaborate, manage lifecycle, access sandboxes, and delete sessions. |
+| Viewer | Read every session; no create, collaborate, lifecycle, sandbox, or delete access. |
+
+Custom roles may contain any registered session operation permission. Custom roles cannot express
+private, invitation-only, creator-only, or participant-only session access.
+
+### Actor-backed services
+
+A bot service acting for a human uses the intersection of two operation sets:
+
+```text
+effective operations = actor role permissions intersect service capability ceiling
+```
+
+The represented actor must resolve to an active canonical workspace user. The service cannot exceed
+the actor's role or its own ceiling. If both grant `sessions.collaborate`, the actor may collaborate
+on any session, including a session created by another user. This preserves multiplayer Slack,
+GitHub, and Linear workflows.
+
+Actorless service calls remain limited to narrow route-specific grants.
+
+### Creator attribution
+
+`sessions.user_id` records the canonical user responsible for creating a session. It supports
+display, filtering, auditing, credential selection, automation lineage, and other attribution needs.
+It is not an authorization relationship.
+
+The `Mine` session-list filter continues to select sessions by creator attribution. It is a user
+filter, not an access boundary.
+
+### Participant identity
+
+Session Durable Object participants identify message authors and connected clients. They may retain:
+
+- provider identity and canonical user linkage;
+- display and SCM metadata;
+- message attribution;
+- presence identity;
+- WebSocket token ownership.
+
+Participant existence and the persisted `owner` or `member` value do not authorize session
+operations. Joining or contributing to a session does not create a separate authorization grant.
+
+Participant-management APIs that exist only to maintain access-control relationships are removed.
+Runtime participant creation required for attribution remains internal to contribution and
+WebSocket-token flows.
+
+### WebSockets
+
+WebSocket token issuance and subscription require an active canonical user with
+`sessions.collaborate`. Tokens remain bound to their session and participant identity. Subscription
+authorization is rechecked through bounded leases so suspension or role changes affect live access.
+
+The authorization recheck evaluates active workspace membership and `sessions.collaborate`; it does
+not evaluate creator or participant access records.
+
+### Sandbox capabilities
+
+Human or actor-backed requests for sandbox credentials require `sessions.sandbox_access`, which
+applies workspace-wide. Sandbox-originated control-plane requests continue to authenticate with a
+session-bound sandbox capability and remain restricted to that session.
+
+Human workspace authorization and sandbox capability binding are separate security boundaries.
+
+### Lifecycle and state checks
+
+Lifecycle routes require `sessions.lifecycle` for every session. Session state-machine checks,
+queued-work checks, and sandbox runtime constraints continue to apply.
+
+Durable Object participant existence is not a lifecycle authorization condition. Rename, archive,
+and unarchive follow the same workspace permission policy as stop, retry, and refresh.
+
+### Service and UI metadata
+
+Session lists are not filtered by authorization relationships. Query filters such as creator and
+status remain supported.
+
+The web client derives lifecycle-control visibility from the current user's workspace
+`sessions.lifecycle` permission. Session list and inbox responses contain session data, not
+authorization presentation metadata; lifecycle endpoints remain authoritative.
+
+## Removed Model
+
+The RBAC foundation does not include:
+
+- a D1 `session_access` table;
+- creator or participant authorization projections;
+- `.own` and `.any` session permission pairs;
+- relationship-filtered session or inbox queries;
+- relationship activation during WebSocket token issuance;
+- relationship-aware user merge behavior;
+- creator-only deletion or participant management;
+- bot-specific narrowing to sessions associated with the represented actor.
+
+Because this schema and permission model were introduced on the unshipped RBAC branch, they are
+removed directly from the branch migration and permission registry rather than retained as a
+compatibility layer.
+
+## Deferred Features
+
+Private, invitation-only, creator-restricted, or participant-restricted sessions require a separate
+product design. Such a design must address visibility, invitations, removal, revocation, historical
+participants, bot behavior, parent-child sessions, cross-store consistency, migration, and UI.
+
+No relationship schema or permission identifiers are retained speculatively for that future work.
+
+## Invariants
+
+- A workspace permission has the same meaning for browser users and represented bot actors.
+- A service may narrow an actor's operations but may not expand them.
+- Session creator and participant data are attribution and runtime identity, not authorization.
+- Every user with `sessions.read` can read and list every session.
+- Every user with `sessions.collaborate` can contribute to every session.
+- Every user with `sessions.lifecycle` can invoke lifecycle operations on every session.
+- Every user with `sessions.sandbox_access` can request sandbox access for every session.
+- Every user with `sessions.delete` can delete every session.
+- Sandbox credentials remain bound to one session regardless of human workspace permissions.
+- Suspension and role changes apply to new HTTP requests and bounded-lifetime WebSocket leases.
+
+## Verification
+
+The implementation must cover:
+
+- a role-by-operation HTTP authorization matrix;
+- cross-user browser collaboration;
+- cross-user actor-backed bot listing and collaboration;
+- service ceiling denial when the actor role permits an operation the service does not;
+- Viewer read access and mutation denial;
+- workspace-wide lifecycle, sandbox, and deletion behavior for permitted roles;
+- WebSocket subscription reauthorization after role or suspension changes;
+- session-bound sandbox authentication;
+- lifecycle consistency across rename, archive, unarchive, stop, retry, and refresh.
From ade164732018ce2e62e5e16424918a4a3ddbbdf0 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Sun, 30 Aug 2026 22:03:51 -0700
Subject: [PATCH 07/11] chore: preserve original RBAC patch bytes
---
packages/control-plane/src/auth/user/better-auth.ts | 1 -
packages/control-plane/src/db/session-inbox-store.ts | 4 ++++
packages/control-plane/src/db/user-store.ts | 2 +-
packages/shared/src/types/session-inbox.ts | 1 +
4 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/packages/control-plane/src/auth/user/better-auth.ts b/packages/control-plane/src/auth/user/better-auth.ts
index ab082bd19..c9185bd46 100644
--- a/packages/control-plane/src/auth/user/better-auth.ts
+++ b/packages/control-plane/src/auth/user/better-auth.ts
@@ -6,7 +6,6 @@ import { generateId } from "../crypto";
import type { ProviderProfileResolver } from "./provider-profile";
const MS_PER_SECOND = 1000;
-
export const SESSION_EXPIRES_IN_MS = 7 * 24 * 60 * 60 * MS_PER_SECOND;
export const SESSION_UPDATE_AGE_MS = 24 * 60 * 60 * MS_PER_SECOND;
diff --git a/packages/control-plane/src/db/session-inbox-store.ts b/packages/control-plane/src/db/session-inbox-store.ts
index 3574640b3..9eafe0e3d 100644
--- a/packages/control-plane/src/db/session-inbox-store.ts
+++ b/packages/control-plane/src/db/session-inbox-store.ts
@@ -9,6 +9,7 @@ import type { SessionInboxCursor } from "./session-inbox-cursor";
import { readStateFromRow, unreadSql, type ViewerReadStateRow } from "./session-read-state";
import type { SqlDatabase, SqlStatement } from "./sql-database";
+/** Viewer, filtering, and pagination inputs for an inbox query. */
export interface ListSessionInboxOptions {
category: SessionInboxCategory;
createdByUserIds?: readonly string[];
@@ -69,9 +70,11 @@ function toListItem(row: InboxSessionRow): SessionListItem {
};
}
+/** Builds viewer-specific session inbox pages from the D1 session index. */
export class SessionInboxStore {
constructor(private readonly db: SqlDatabase) {}
+ /** List one inbox category with viewer-specific read state. */
async list(options: ListSessionInboxOptions): Promise {
const result = await this.bindInboxQuery(options).all();
const page = this.buildPageData(options.limit, result.results ?? []);
@@ -85,6 +88,7 @@ export class SessionInboxStore {
);
}
+ /** List every inbox category with viewer-specific read state. */
async snapshot(
options: Omit
): Promise {
diff --git a/packages/control-plane/src/db/user-store.ts b/packages/control-plane/src/db/user-store.ts
index c889748be..3cd1d0c23 100644
--- a/packages/control-plane/src/db/user-store.ts
+++ b/packages/control-plane/src/db/user-store.ts
@@ -166,7 +166,7 @@ export class UserStore {
return await this.doResolveOrCreate(identity);
} catch (err) {
if (isUniqueConstraintError(err)) {
- return await this.doResolveOrCreate(identity);
+ return this.doResolveOrCreate(identity);
}
throw err;
}
diff --git a/packages/shared/src/types/session-inbox.ts b/packages/shared/src/types/session-inbox.ts
index 5289f92f2..5e6c3f45b 100644
--- a/packages/shared/src/types/session-inbox.ts
+++ b/packages/shared/src/types/session-inbox.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { PullRequestSummary, SessionReadState, SessionStatus, SpawnSource } from "./sessions";
import type { SessionListRepository } from "./repositories";
+/** Viewer-specific session row returned by list and inbox APIs. */
export interface SessionListItem {
id: string;
title: string | null;
From 4866d41a315772efbaf9f503104daf18738a5506 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Sun, 30 Aug 2026 22:34:24 -0700
Subject: [PATCH 08/11] fix(rbac): address foundation review feedback
---
.github/workflows/ci.yml | 4 +-
package-lock.json | 2 +-
package.json | 2 +-
.../src/authorization/service.ts | 5 +-
.../src/db/authorization-store.test.ts | 29 ++--
.../src/db/authorization-store.ts | 80 ++++++++---
packages/control-plane/src/db/user-merge.ts | 133 +++++++++++++----
.../test/integration/rbac-foundation.test.ts | 118 ++++++++++++++-
.../test/integration/user-merge.test.ts | 134 ++++++++++++++++++
packages/shared/src/rbac.test.ts | 20 +++
packages/shared/src/rbac.ts | 57 ++++++--
scripts/bootstrap-workspace-owner.test.ts | 68 ++++++++-
scripts/bootstrap-workspace-owner.ts | 38 +++--
scripts/merge-split-users.ts | 55 +++----
.../d1/migrations/0071_rbac_foundation.sql | 14 +-
15 files changed, 644 insertions(+), 115 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 712ab6035..f70ac230d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -65,7 +65,9 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
- node-version: "22"
+ # Minimum supported release: node:sqlite is available without an
+ # additional flag and --experimental-transform-types is present.
+ node-version: "22.13.0"
cache: "npm"
- name: Install dependencies
diff --git a/package-lock.json b/package-lock.json
index 329515bac..3c0cb3a55 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,7 +27,7 @@
"wrangler": "^4.103.0"
},
"engines": {
- "node": ">=22.0.0"
+ "node": ">=22.13.0"
}
},
"node_modules/@acemir/cssom": {
diff --git a/package.json b/package.json
index 35871e3ed..5c7a63432 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,7 @@
"wrangler": "^4.103.0"
},
"engines": {
- "node": ">=22.0.0"
+ "node": ">=22.13.0"
},
"overrides": {
"minimatch": "^10.2.5",
diff --git a/packages/control-plane/src/authorization/service.ts b/packages/control-plane/src/authorization/service.ts
index 36da1c984..bab14ebae 100644
--- a/packages/control-plane/src/authorization/service.ts
+++ b/packages/control-plane/src/authorization/service.ts
@@ -156,9 +156,12 @@ export class AuthorizationService {
if (outcome.status === "actor_authorization_changed") {
throw new RbacConflictError("Actor authorization changed");
}
- if (outcome.status === "not_found") {
+ if (outcome.status === "role_not_found") {
throw new AuthorizationError(404, "role_not_found");
}
+ if (outcome.status === "member_not_found") {
+ throw new AuthorizationError(404, "member_not_found");
+ }
if (outcome.status === "conflict") {
throw new RbacConflictError(conflictMessage);
}
diff --git a/packages/control-plane/src/db/authorization-store.test.ts b/packages/control-plane/src/db/authorization-store.test.ts
index 780045503..8830ccf91 100644
--- a/packages/control-plane/src/db/authorization-store.test.ts
+++ b/packages/control-plane/src/db/authorization-store.test.ts
@@ -62,20 +62,23 @@ describe("AuthorizationStore", () => {
]);
});
- it.each(["applied", "actor_authorization_changed", "not_found", "conflict"] as const)(
- "returns the %s member status replacement batch outcome",
- async (status) => {
- const store = new AuthorizationStore(
- fakeDatabase({
- batchResults: [result(0, [{ status }]), result(1), result(1), result(1)],
- })
- );
+ it.each([
+ "applied",
+ "actor_authorization_changed",
+ "role_not_found",
+ "member_not_found",
+ "conflict",
+ ] as const)("returns the %s member status replacement batch outcome", async (status) => {
+ const store = new AuthorizationStore(
+ fakeDatabase({
+ batchResults: [result(0, [{ status }]), result(1), result(1), result(1)],
+ })
+ );
- await expect(store.replaceMemberStatus(replaceMemberStatusInput)).resolves.toEqual({
- status,
- });
- }
- );
+ await expect(store.replaceMemberStatus(replaceMemberStatusInput)).resolves.toEqual({
+ status,
+ });
+ });
it("does not classify an unexpected database failure as a conflict", async () => {
const failure = new Error("database unavailable");
diff --git a/packages/control-plane/src/db/authorization-store.ts b/packages/control-plane/src/db/authorization-store.ts
index f03b54605..1bd249121 100644
--- a/packages/control-plane/src/db/authorization-store.ts
+++ b/packages/control-plane/src/db/authorization-store.ts
@@ -1,7 +1,9 @@
import {
BUILT_IN_ROLE_REGISTRY,
+ roleReferenceSchema,
type BuiltInRoleKey,
type PermissionId,
+ type RoleReference,
type WorkspaceMember,
} from "@open-inspect/shared/rbac";
import { rolePermissionPredicate } from "../authorization/permission-sql";
@@ -39,17 +41,14 @@ interface MemberRow {
export interface EffectiveAuthorizationRecord {
userId: string;
suspendedAt: number | null;
- role: { id: string; key: BuiltInRoleKey | null; name: string } | null;
+ role: RoleReference | null;
}
/** Persistence view of a role and the number of users currently assigned to it. */
-export interface AuthorizationRoleRecord {
- id: string;
- key: BuiltInRoleKey | null;
- name: string;
+export type AuthorizationRoleRecord = RoleReference & {
description: string | null;
assignmentCount: number;
-}
+};
interface AuditInput {
requestId: string;
@@ -93,25 +92,33 @@ function anotherUnsuspendedOwner(targetUserId: string): SqlCondition {
export type AuthorizationMutationOutcome =
| { status: "applied" }
| { status: "actor_authorization_changed" }
- | { status: "not_found" }
+ | { status: "role_not_found" }
+ | { status: "member_not_found" }
| { status: "conflict" };
+type NotFoundStatus = Extract<
+ AuthorizationMutationOutcome["status"],
+ "role_not_found" | "member_not_found"
+>;
+
+function toRoleReference(id: string, key: BuiltInRoleKey | null, name: string): RoleReference {
+ return roleReferenceSchema.parse({ id, key, name });
+}
+
function toEffectiveAuthorizationRecord(row: EffectiveRow): EffectiveAuthorizationRecord {
return {
userId: row.user_id,
suspendedAt: row.suspended_at,
role:
row.role_id && row.role_name
- ? { id: row.role_id, key: row.role_key, name: row.role_name }
+ ? toRoleReference(row.role_id, row.role_key, row.role_name)
: null,
};
}
function toRoleRecord(row: RoleRow): AuthorizationRoleRecord {
return {
- id: row.id,
- key: row.key,
- name: row.name,
+ ...toRoleReference(row.id, row.key, row.name),
description: row.description,
assignmentCount: Number(row.assignment_count),
};
@@ -123,7 +130,7 @@ function toMember(row: MemberRow): WorkspaceMember {
displayName: row.display_name,
email: row.email,
suspendedAt: row.suspended_at,
- role: { id: row.role_id, key: row.role_key, name: row.role_name },
+ role: toRoleReference(row.role_id, row.role_key, row.role_name),
};
}
@@ -240,6 +247,22 @@ export class AuthorizationStore {
sql: `(? <> ? AND NOT (${targetIsOwner.sql})) OR ${transferGuard.sql}`,
values: [input.roleId, OWNER_ROLE_ID, ...targetIsOwner.values, ...transferGuard.values],
},
+ notFound: [
+ {
+ status: "role_not_found",
+ condition: {
+ sql: "NOT EXISTS (SELECT 1 FROM roles WHERE id = ?)",
+ values: [input.roleId],
+ },
+ },
+ {
+ status: "member_not_found",
+ condition: {
+ sql: "NOT EXISTS (SELECT 1 FROM user_role_assignments WHERE user_id = ?)",
+ values: [input.targetUserId],
+ },
+ },
+ ],
}
);
const results = await this.db.batch([
@@ -308,6 +331,19 @@ export class AuthorizationStore {
sql: `NOT (${targetIsOwner.sql}) OR ${transferGuard.sql}`,
values: [...targetIsOwner.values, ...transferGuard.values],
},
+ notFound: [
+ {
+ status: "member_not_found",
+ condition: {
+ sql: `NOT EXISTS (
+ SELECT 1 FROM users
+ JOIN user_role_assignments ON user_role_assignments.user_id = users.id
+ WHERE users.id = ?
+ )`,
+ values: [input.targetUserId],
+ },
+ },
+ ],
}
);
const statements: SqlStatement[] = [
@@ -355,7 +391,10 @@ export class AuthorizationStore {
actorUserId: string,
permissions: PermissionId[],
resourceCondition: SqlCondition,
- options?: { actor?: SqlCondition; notFound?: SqlCondition }
+ options?: {
+ actor?: SqlCondition;
+ notFound?: Array<{ status: NotFoundStatus; condition: SqlCondition }>;
+ }
): {
outcome: SqlStatement;
applied: SqlCondition;
@@ -383,17 +422,25 @@ export class AuthorizationStore {
values: [...actor.values, ...resourceCondition.values],
};
const auditId = crypto.randomUUID();
+ const notFoundCases =
+ options?.notFound
+ ?.map(({ status, condition }) => `WHEN (${condition.sql}) THEN '${status}'`)
+ .join("\n ") ?? "";
return {
outcome: this.db
.prepare(
`SELECT CASE
WHEN NOT (${actor.sql}) THEN 'actor_authorization_changed'
- ${options?.notFound ? `WHEN (${options.notFound.sql}) THEN 'not_found'` : ""}
+ ${notFoundCases}
WHEN NOT (${resourceCondition.sql}) THEN 'conflict'
ELSE 'applied'
END AS status`
)
- .bind(...actor.values, ...(options?.notFound?.values ?? []), ...resourceCondition.values),
+ .bind(
+ ...actor.values,
+ ...(options?.notFound?.flatMap(({ condition }) => condition.values) ?? []),
+ ...resourceCondition.values
+ ),
applied,
writes: {
sql: "EXISTS (SELECT 1 FROM authorization_audit_events WHERE id = ?)",
@@ -408,7 +455,8 @@ export class AuthorizationStore {
if (
status !== "applied" &&
status !== "actor_authorization_changed" &&
- status !== "not_found" &&
+ status !== "role_not_found" &&
+ status !== "member_not_found" &&
status !== "conflict"
) {
throw new Error("Invalid authorization mutation outcome");
diff --git a/packages/control-plane/src/db/user-merge.ts b/packages/control-plane/src/db/user-merge.ts
index e098a2815..95e3d190c 100644
--- a/packages/control-plane/src/db/user-merge.ts
+++ b/packages/control-plane/src/db/user-merge.ts
@@ -20,12 +20,9 @@ import type { SqlDatabase, SqlResult, SqlStatement } from "./sql-database";
* `idx_user_identities_provider`).
* - `automations.created_by` is re-pointed value-conditionally: legacy rows
* store GitHub numeric ids, which must never be rewritten.
- * - Idempotent: re-running a completed merge is a zero-count no-op, and a
- * partially-applied run is repaired by running the script again — with one
- * exception: the final email backfill's input (the loser row) is deleted by
- * the preceding statement, so a stop exactly between those two statements
- * is not re-derivable from the database. The CLI prints a recovery record
- * before executing to cover that residual case.
+ * - Idempotent: re-running a completed merge is a zero-count no-op. The
+ * execute path requires an atomic SqlDatabase batch so no partial graph can
+ * become externally visible.
* - Browser sessions (`auth_sessions`) issued to the loser are deleted. An
* issued bearer credential is never rewritten to authenticate as another
* canonical user.
@@ -74,6 +71,15 @@ const USER_MERGE_COUNT_KEYS = [
"roleAssignmentsRemoved",
"providerAccountAuthorizationsRepointed",
"providerAccountAuthorizationAttemptsRepointed",
+ "providerAccountsCreatedRepointed",
+ "providerAccountsUpdatedRepointed",
+ "providerAccountDefaultsCreatedRepointed",
+ "providerAccountDefaultsUpdatedRepointed",
+ "skillsCreatedRepointed",
+ "skillsUpdatedRepointed",
+ "skillRevisionsCreatedRepointed",
+ "skillAssignmentsCreatedRepointed",
+ "skillCatalogGenerationsAdvanced",
"keyboardShortcutPreferencesDeduped",
"keyboardShortcutPreferencesRepointed",
"auditEventsCreated",
@@ -84,6 +90,12 @@ const USER_MERGE_COUNT_KEYS = [
type UserMergeCountKey = (typeof USER_MERGE_COUNT_KEYS)[number];
type UserMergeCounts = Record;
+const RESULT_CHANGE_DIVISORS: Partial> = {
+ // The assignment UPDATE trigger also advances skills_catalog_state once per
+ // changed assignment, and D1 includes both rows in meta.changes.
+ skillAssignmentsCreatedRepointed: 2,
+};
+
interface MergeOperation {
readonly key: UserMergeCountKey;
readonly execute: (db: SqlDatabase, survivorId: string, loserId: string) => SqlStatement;
@@ -178,12 +190,48 @@ const SKILL_PROFILE_OPERATIONS = dedupeThenRepoint({
)`,
});
+const SKILL_CATALOG_GENERATION_OPERATION: MergeOperation = {
+ key: "skillCatalogGenerationsAdvanced",
+ execute: (db, _survivorId, loserId) =>
+ db
+ .prepare(
+ `UPDATE skills_catalog_state SET generation = generation + 1
+ WHERE singleton = 1
+ AND EXISTS (SELECT 1 FROM skill_profiles WHERE user_id = ?)`
+ )
+ .bind(loserId),
+ preview: (db, _survivorId, loserId) =>
+ db
+ .prepare(
+ `SELECT COUNT(*) AS count FROM skills_catalog_state
+ WHERE singleton = 1
+ AND EXISTS (SELECT 1 FROM skill_profiles WHERE user_id = ?)`
+ )
+ .bind(loserId),
+};
+
const FINAL_REPOINT_OPERATIONS = [
regularRepoint("providerAccountAuthorizationsRepointed", "model_provider_account_authorizations"),
regularRepoint(
"providerAccountAuthorizationAttemptsRepointed",
"model_provider_account_authorization_attempts"
),
+ regularRepoint("providerAccountsCreatedRepointed", "model_provider_accounts", "created_by"),
+ regularRepoint("providerAccountsUpdatedRepointed", "model_provider_accounts", "updated_by"),
+ regularRepoint(
+ "providerAccountDefaultsCreatedRepointed",
+ "model_provider_account_defaults",
+ "created_by"
+ ),
+ regularRepoint(
+ "providerAccountDefaultsUpdatedRepointed",
+ "model_provider_account_defaults",
+ "updated_by"
+ ),
+ regularRepoint("skillsCreatedRepointed", "skills", "created_by"),
+ regularRepoint("skillsUpdatedRepointed", "skills", "updated_by"),
+ regularRepoint("skillRevisionsCreatedRepointed", "skill_revisions", "created_by"),
+ regularRepoint("skillAssignmentsCreatedRepointed", "skill_assignments", "created_by"),
...dedupeThenRepoint({
dedupeKey: "keyboardShortcutPreferencesDeduped",
repointKey: "keyboardShortcutPreferencesRepointed",
@@ -194,6 +242,7 @@ const FINAL_REPOINT_OPERATIONS = [
const TABLE_OPERATIONS = [
...BEFORE_SKILL_PROFILE_OPERATIONS,
+ SKILL_CATALOG_GENERATION_OPERATION,
...SKILL_PROFILE_OPERATIONS,
...FINAL_REPOINT_OPERATIONS,
] as const;
@@ -229,14 +278,15 @@ export async function mergeUsers(
throw new UserMergeError(`Survivor user ${survivorId} not found`);
}
// A missing loser row is not an error: re-running a completed merge must
- // be a no-op, and a partially-applied merge must be resumable.
+ // be a no-op after an already-completed atomic merge.
const loser = await db
- .prepare(`SELECT id, email, email_verified FROM users WHERE id = ?`)
+ .prepare(`SELECT id, email, email_verified, suspended_at FROM users WHERE id = ?`)
.bind(loserId)
.first<{
id: string;
email: string | null;
email_verified: number;
+ suspended_at: number | null;
}>();
if (!loser) {
return { survivorId, loserId, dryRun: options.dryRun === true, counts: emptyCounts() };
@@ -269,6 +319,9 @@ export async function mergeUsers(
if (survivorRole && loserRole && survivorRole.role_id !== loserRole.role_id) {
throw new UserMergeError("Resolve conflicting user roles before merging");
}
+ if (survivor.suspended_at !== loser.suspended_at) {
+ throw new UserMergeError("Resolve conflicting user suspension states before merging");
+ }
if (loserRole?.role_key === "owner" && survivor.suspended_at !== null) {
throw new UserMergeError("The surviving Owner must be active before merging");
}
@@ -298,11 +351,54 @@ export async function mergeUsers(
}
};
+ const auditId = crypto.randomUUID();
+ const occurredAt = Date.now();
+ // The NOT NULL occurred_at column turns a failed revalidation into a batch
+ // error, rolling back every merge write. This closes the preflight/write
+ // window for role, suspension, and last-active-Owner invariants.
+ add(
+ "auditEventsCreated",
+ db
+ .prepare(
+ `INSERT INTO authorization_audit_events
+ (id, occurred_at, request_id, principal_kind,
+ actor_service_snapshot, action, resource_type, resource_id,
+ target_user_id_snapshot, reason_code)
+ VALUES (
+ ?,
+ CASE WHEN EXISTS (
+ SELECT 1
+ FROM users survivor
+ JOIN user_role_assignments survivor_assignment
+ ON survivor_assignment.user_id = survivor.id
+ JOIN users loser ON loser.id = ?
+ JOIN user_role_assignments loser_assignment
+ ON loser_assignment.user_id = loser.id
+ JOIN roles role ON role.id = loser_assignment.role_id
+ WHERE survivor.id = ?
+ AND survivor_assignment.role_id = loser_assignment.role_id
+ AND survivor.suspended_at IS loser.suspended_at
+ AND (role.key IS NULL OR role.key <> 'owner' OR survivor.suspended_at IS NULL)
+ ) THEN ? ELSE NULL END,
+ 'user-merge', 'service', 'control-plane',
+ 'workspace.user_merged', 'user', ?, ?, 'operator_merge'
+ )`
+ )
+ .bind(auditId, loserId, survivorId, occurredAt, survivorId, loserId)
+ );
+
// Dedup before re-pointing: drop loser rows whose target slot the survivor
// already occupies (identities under idx_user_identities_provider; read
// states routinely, where both split rows read the same session).
addOperations(BEFORE_SKILL_PROFILE_OPERATIONS);
+ // Profile resolution uses this generation as a consistency fence. Advance
+ // it before any profile membership or ownership rows are changed.
+ add(
+ SKILL_CATALOG_GENERATION_OPERATION.key,
+ SKILL_CATALOG_GENERATION_OPERATION.execute(db, survivorId, loserId)
+ );
+
// Merge items before deleting colliding skill profiles.
add(
"skillProfileItemsMerged",
@@ -327,21 +423,6 @@ export async function mergeUsers(
);
addOperations(FINAL_REPOINT_OPERATIONS);
- // Record the merge before deleting the user so the snapshots remain explicit.
- add(
- "auditEventsCreated",
- db
- .prepare(
- `INSERT INTO authorization_audit_events
- (id, occurred_at, request_id, principal_kind,
- actor_service_snapshot, action, resource_type, resource_id,
- target_user_id_snapshot, reason_code)
- VALUES (?, ?, 'user-merge', 'service', 'control-plane',
- 'workspace.user_merged', 'user', ?, ?, 'operator_merge')`
- )
- .bind(crypto.randomUUID(), Date.now(), survivorId, loserId)
- );
-
add("usersDeleted", db.prepare(`DELETE FROM users WHERE id = ?`).bind(loserId));
if (backfillEmail) {
// A blank-or-NULL-email survivor acquires the email freed by the loser's
@@ -367,7 +448,7 @@ export async function mergeUsers(
const counts = emptyCounts();
for (const [key, index] of Object.entries(track) as [UserMergeCountKey, number][]) {
- counts[key] = results[index]?.meta.changes ?? 0;
+ counts[key] = (results[index]?.meta.changes ?? 0) / (RESULT_CHANGE_DIVISORS[key] ?? 1);
}
if (loser) {
// The users delete's reported `changes` includes any FK-cascaded rows;
@@ -438,7 +519,9 @@ async function previewCounts(
...operationCounts,
skillProfileItemsMerged: count(skillProfileItemsMerged),
roleAssignmentsRemoved: count(roleAssignments),
- auditEventsCreated: count(users),
+ // mergeUsers returns before previewing when the loser is absent, so an
+ // executed merge always writes exactly one audit event.
+ auditEventsCreated: 1,
canonicalEmailBackfilled,
usersDeleted: count(users),
};
diff --git a/packages/control-plane/test/integration/rbac-foundation.test.ts b/packages/control-plane/test/integration/rbac-foundation.test.ts
index bb73a61d8..a73bdb9ba 100644
--- a/packages/control-plane/test/integration/rbac-foundation.test.ts
+++ b/packages/control-plane/test/integration/rbac-foundation.test.ts
@@ -4,7 +4,16 @@ import {
PERMISSION_IDS,
permissionsForBuiltInRole,
} from "@open-inspect/shared/rbac";
-import { describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it } from "vitest";
+import { AuthorizationStore } from "../../src/db/authorization-store";
+import { AuthorizationService } from "../../src/authorization/service";
+import { cleanD1Tables } from "./cleanup";
+import { insertCanonicalUser } from "./identity-seed-helpers";
+
+const ACTOR_ID = "11111111111111111111111111111111";
+const TARGET_ID = "22222222222222222222222222222222";
+
+beforeEach(cleanD1Tables);
describe("RBAC foundation migration", () => {
it("seeds built-in roles without persisting their code-owned permissions", async () => {
@@ -24,4 +33,111 @@ describe("RBAC foundation migration", () => {
).toEqual({ count: 0 });
expect(permissionsForBuiltInRole("owner")).toHaveLength(PERMISSION_IDS.length);
});
+
+ it("rejects non-canonical system role identities and reserved IDs used as custom roles", async () => {
+ await expect(
+ env.DB.prepare(
+ `INSERT INTO roles (id, key, name, normalized_name, is_system)
+ VALUES ('role_system_alias', NULL, 'Alias', 'alias', 1)`
+ ).run()
+ ).rejects.toThrow();
+
+ await expect(
+ env.DB.prepare(
+ `UPDATE roles SET key = NULL, is_system = 0
+ WHERE id = 'role_builtin_owner'`
+ ).run()
+ ).rejects.toThrow();
+
+ await expect(
+ env.DB.prepare(
+ `UPDATE roles SET key = NULL
+ WHERE id = 'role_builtin_owner'`
+ ).run()
+ ).rejects.toThrow();
+ });
+
+ it("classifies missing roles and members through real D1 mutation SQL", async () => {
+ await insertCanonicalUser({ id: ACTOR_ID, email: "owner@example.com" });
+ await insertCanonicalUser({ id: TARGET_ID, email: "member@example.com" });
+ await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?")
+ .bind(BUILT_IN_ROLE_REGISTRY.owner.id, ACTOR_ID)
+ .run();
+ const store = new AuthorizationStore(env.DB);
+
+ await expect(
+ store.replaceMemberRole({
+ actorUserId: ACTOR_ID,
+ targetUserId: TARGET_ID,
+ roleId: "role_missing",
+ requestId: "missing-role",
+ now: 100,
+ })
+ ).resolves.toEqual({ status: "role_not_found" });
+ await expect(
+ store.replaceMemberRole({
+ actorUserId: ACTOR_ID,
+ targetUserId: "33333333333333333333333333333333",
+ roleId: BUILT_IN_ROLE_REGISTRY.viewer.id,
+ requestId: "missing-role-target",
+ now: 101,
+ })
+ ).resolves.toEqual({ status: "member_not_found" });
+ await expect(
+ store.replaceMemberStatus({
+ actorUserId: ACTOR_ID,
+ targetUserId: "33333333333333333333333333333333",
+ suspended: true,
+ requestId: "missing-status-target",
+ now: 102,
+ })
+ ).resolves.toEqual({ status: "member_not_found" });
+
+ const service = new AuthorizationService(env.DB);
+ await expect(
+ service.replaceMemberRole({
+ actorUserId: ACTOR_ID,
+ targetUserId: TARGET_ID,
+ roleId: "role_missing",
+ requestId: "missing-role-service",
+ })
+ ).rejects.toMatchObject({ status: 404, code: "role_not_found" });
+ await expect(
+ service.replaceMemberStatus({
+ actorUserId: ACTOR_ID,
+ targetUserId: "33333333333333333333333333333333",
+ suspended: true,
+ requestId: "missing-member-service",
+ })
+ ).rejects.toMatchObject({ status: 404, code: "member_not_found" });
+ });
+
+ it("applies and audits a member mutation through real D1 SQL", async () => {
+ await insertCanonicalUser({ id: ACTOR_ID, email: "owner@example.com" });
+ await insertCanonicalUser({ id: TARGET_ID, email: "member@example.com" });
+ await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?")
+ .bind(BUILT_IN_ROLE_REGISTRY.owner.id, ACTOR_ID)
+ .run();
+ const store = new AuthorizationStore(env.DB);
+
+ await expect(
+ store.replaceMemberRole({
+ actorUserId: ACTOR_ID,
+ targetUserId: TARGET_ID,
+ roleId: BUILT_IN_ROLE_REGISTRY.viewer.id,
+ requestId: "apply-role",
+ now: 200,
+ })
+ ).resolves.toEqual({ status: "applied" });
+ await expect(
+ env.DB.prepare(
+ `SELECT action, request_id, target_user_id_snapshot
+ FROM authorization_audit_events WHERE request_id = 'apply-role'`
+ ).first()
+ ).resolves.toEqual({
+ action: "workspace.member_role_updated",
+ request_id: "apply-role",
+ target_user_id_snapshot: TARGET_ID,
+ });
+ });
});
diff --git a/packages/control-plane/test/integration/user-merge.test.ts b/packages/control-plane/test/integration/user-merge.test.ts
index 43d6272c1..75e8d66ee 100644
--- a/packages/control-plane/test/integration/user-merge.test.ts
+++ b/packages/control-plane/test/integration/user-merge.test.ts
@@ -1,6 +1,7 @@
import { env } from "cloudflare:test";
import { beforeEach, describe, expect, it } from "vitest";
import { mergeUsers, UserMergeError } from "../../src/db/user-merge";
+import type { SqlDatabase, SqlResult, SqlStatement } from "../../src/db/sql-database";
import { cleanD1Tables } from "./cleanup";
import {
SEED_NOW_MS,
@@ -115,6 +116,7 @@ describe("mergeUsers", () => {
automationsCreatedRepointed: 1,
scmTokensRepointed: 1,
skillProfilesRepointed: 1,
+ skillCatalogGenerationsAdvanced: 1,
readStatesDeduped: 1,
readStatesRepointed: 1,
usersDeleted: 1,
@@ -158,6 +160,11 @@ describe("mergeUsers", () => {
).toEqual({ last_read_message_id: "msg-survivor" });
expect(await getUserRow(LOSER)).toBeNull();
expect(await countTableRows("users")).toBe(1);
+ expect(
+ await env.DB.prepare(
+ "SELECT generation FROM skills_catalog_state WHERE singleton = 1"
+ ).first()
+ ).toEqual({ generation: 1 });
expect(
await env.DB.prepare(
`SELECT principal_kind, actor_user_id_snapshot, actor_service_snapshot,
@@ -308,6 +315,88 @@ describe("mergeUsers", () => {
).toEqual({ shortcuts: "{}" });
});
+ it("preserves canonical attribution across provider accounts and managed skills", async () => {
+ await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
+ await insertCanonicalUser({ id: LOSER, email: null });
+ await env.DB.batch([
+ env.DB.prepare(
+ `INSERT INTO model_provider_accounts
+ (id, provider, display_name, status, created_by, updated_by, created_at, updated_at)
+ VALUES ('provider-account', 'openai', 'Personal', 'active', ?, ?, 1, 1)`
+ ).bind(LOSER, LOSER),
+ env.DB.prepare(
+ `INSERT INTO model_provider_account_defaults
+ (provider, provider_account_id, created_by, updated_by, created_at, updated_at)
+ VALUES ('openai', 'provider-account', ?, ?, 1, 1)`
+ ).bind(LOSER, LOSER),
+ env.DB.prepare(
+ `INSERT INTO skills
+ (id, name, enabled, created_by, updated_by, created_at, updated_at)
+ VALUES ('skill-1', 'Skill One', 1, ?, ?, 1, 1)`
+ ).bind(LOSER, LOSER),
+ env.DB.prepare(
+ `INSERT INTO skill_revisions
+ (id, skill_id, revision_number, revision_sha256, description, body,
+ metadata_json, total_bytes, created_by, created_at)
+ VALUES ('revision-1', 'skill-1', 1, ?, 'Description', 'Body', '{}', 4, ?, 1)`
+ ).bind("a".repeat(64), LOSER),
+ ]);
+ await env.DB.batch([
+ env.DB.prepare("UPDATE skills SET current_revision_id = 'revision-1' WHERE id = 'skill-1'"),
+ env.DB.prepare(
+ `INSERT INTO skill_assignments
+ (id, skill_id, scope_type, created_by, created_at)
+ VALUES ('assignment-1', 'skill-1', 'global', ?, 1)`
+ ).bind(LOSER),
+ ]);
+
+ const preview = await mergeUsers(env.DB, {
+ survivorId: SURVIVOR,
+ loserId: LOSER,
+ dryRun: true,
+ });
+ const result = await mergeUsers(env.DB, { survivorId: SURVIVOR, loserId: LOSER });
+
+ expect(preview.counts).toMatchObject({
+ providerAccountsCreatedRepointed: 1,
+ providerAccountsUpdatedRepointed: 1,
+ providerAccountDefaultsCreatedRepointed: 1,
+ providerAccountDefaultsUpdatedRepointed: 1,
+ skillsCreatedRepointed: 1,
+ skillsUpdatedRepointed: 1,
+ skillRevisionsCreatedRepointed: 1,
+ skillAssignmentsCreatedRepointed: 1,
+ });
+ expect(result.counts).toEqual(preview.counts);
+ expect(
+ await env.DB.prepare(
+ `SELECT created_by, updated_by FROM model_provider_accounts
+ WHERE id = 'provider-account'`
+ ).first()
+ ).toEqual({ created_by: SURVIVOR, updated_by: SURVIVOR });
+ expect(
+ await env.DB.prepare(
+ `SELECT created_by, updated_by FROM model_provider_account_defaults
+ WHERE provider = 'openai'`
+ ).first()
+ ).toEqual({ created_by: SURVIVOR, updated_by: SURVIVOR });
+ expect(
+ await env.DB.prepare(
+ `SELECT s.created_by, s.updated_by, r.created_by AS revision_created_by,
+ a.created_by AS assignment_created_by
+ FROM skills s
+ JOIN skill_revisions r ON r.id = 'revision-1'
+ JOIN skill_assignments a ON a.id = 'assignment-1'
+ WHERE s.id = 'skill-1'`
+ ).first()
+ ).toEqual({
+ created_by: SURVIVOR,
+ updated_by: SURVIVOR,
+ revision_created_by: SURVIVOR,
+ assignment_created_by: SURVIVOR,
+ });
+ });
+
it("keeps keyboard preference collision preview and execution counts aligned", async () => {
await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
await insertCanonicalUser({ id: LOSER, email: null });
@@ -355,6 +444,51 @@ describe("mergeUsers", () => {
expect(await countTableRows("users")).toBe(1);
});
+ it("rejects a suspended loser merging into an active survivor", async () => {
+ await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
+ await insertCanonicalUser({ id: LOSER, email: null });
+ await env.DB.prepare("UPDATE users SET suspended_at = 123 WHERE id = ?").bind(LOSER).run();
+
+ await expect(mergeUsers(env.DB, { survivorId: SURVIVOR, loserId: LOSER })).rejects.toThrow(
+ /suspension states/
+ );
+ expect(await getUserRow(LOSER)).not.toBeNull();
+ });
+
+ it("rolls back when role invariants change after preflight", async () => {
+ await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
+ await insertCanonicalUser({ id: LOSER, email: null });
+ let batchCount = 0;
+ const racingDatabase: SqlDatabase = {
+ prepare(query: string): SqlStatement {
+ return env.DB.prepare(query) as unknown as SqlStatement;
+ },
+ async batch(statements: SqlStatement[]): Promise[]> {
+ batchCount += 1;
+ if (batchCount === 2) {
+ await env.DB.prepare(
+ "UPDATE user_role_assignments SET role_id = 'role_builtin_viewer' WHERE user_id = ?"
+ )
+ .bind(SURVIVOR)
+ .run();
+ }
+ return env.DB.batch(statements as unknown as D1PreparedStatement[]) as Promise<
+ SqlResult[]
+ >;
+ },
+ };
+
+ await expect(
+ mergeUsers(racingDatabase, { survivorId: SURVIVOR, loserId: LOSER })
+ ).rejects.toThrow();
+ expect(await getUserRow(LOSER)).not.toBeNull();
+ expect(
+ await env.DB.prepare(
+ "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'workspace.user_merged'"
+ ).first()
+ ).toEqual({ count: 0 });
+ });
+
it("rejects a missing survivor and a self-merge", async () => {
await insertCanonicalUser({ id: LOSER, email: null });
diff --git a/packages/shared/src/rbac.test.ts b/packages/shared/src/rbac.test.ts
index 7e3853a9c..352098523 100644
--- a/packages/shared/src/rbac.test.ts
+++ b/packages/shared/src/rbac.test.ts
@@ -9,6 +9,7 @@ import {
resolveScopedPermission,
replaceMemberRoleInputSchema,
replaceMemberStatusInputSchema,
+ roleReferenceSchema,
} from "./rbac";
describe("RBAC registry", () => {
@@ -39,6 +40,25 @@ describe("RBAC registry", () => {
);
});
+ it("binds built-in role IDs and keys into one canonical identity", () => {
+ expect(
+ roleReferenceSchema.parse({ id: "role_builtin_owner", key: "owner", name: "Owner" })
+ ).toEqual({ id: "role_builtin_owner", key: "owner", name: "Owner" });
+ expect(
+ roleReferenceSchema.parse({ id: "role_custom_reviewer", key: null, name: "Reviewer" })
+ ).toEqual({ id: "role_custom_reviewer", key: null, name: "Reviewer" });
+
+ expect(() =>
+ roleReferenceSchema.parse({ id: "role_other", key: "owner", name: "Owner" })
+ ).toThrow();
+ expect(() =>
+ roleReferenceSchema.parse({ id: "role_builtin_owner", key: null, name: "Custom" })
+ ).toThrow();
+ expect(() =>
+ roleReferenceSchema.parse({ id: "role_builtin_member", key: "viewer", name: "Viewer" })
+ ).toThrow();
+ });
+
it("contains unique, sorted permission identifiers", () => {
expect(PERMISSION_IDS).toHaveLength(42);
expect(new Set(PERMISSION_IDS).size).toBe(PERMISSION_IDS.length);
diff --git a/packages/shared/src/rbac.ts b/packages/shared/src/rbac.ts
index 5ebf1af74..caad28048 100644
--- a/packages/shared/src/rbac.ts
+++ b/packages/shared/src/rbac.ts
@@ -25,6 +25,8 @@ export const BUILT_IN_ROLE_REGISTRY = {
export type BuiltInRoleKey = keyof typeof BUILT_IN_ROLE_REGISTRY;
/** Built-in role keys in canonical registry order. */
export const BUILT_IN_ROLE_KEYS = Object.keys(BUILT_IN_ROLE_REGISTRY) as BuiltInRoleKey[];
+/** Stable IDs reserved for system-defined roles. */
+export const BUILT_IN_ROLE_IDS = Object.values(BUILT_IN_ROLE_REGISTRY).map((role) => role.id);
/** Canonical permission identifiers accepted by the RBAC policy and persistence layers. */
export const PERMISSION_IDS = [
@@ -155,21 +157,52 @@ export function isCustomRolePermission(permission: PermissionId): boolean {
return permission !== "workspace.transfer_ownership";
}
+const roleNameSchema = z.string().min(1);
+const roleReferenceShape = {
+ id: z.string().min(1),
+ key: builtInRoleKeySchema.nullable(),
+ name: roleNameSchema,
+};
+
+function validateRoleIdentity(
+ role: { id: string; key: BuiltInRoleKey | null },
+ context: z.RefinementCtx
+): void {
+ if (role.key === null) {
+ if ((BUILT_IN_ROLE_IDS as readonly string[]).includes(role.id)) {
+ context.addIssue({
+ code: "custom",
+ path: ["id"],
+ message: "Built-in role IDs require their canonical key",
+ });
+ }
+ return;
+ }
+ if (role.id !== BUILT_IN_ROLE_REGISTRY[role.key].id) {
+ context.addIssue({
+ code: "custom",
+ path: ["id"],
+ message: "Built-in role keys require their canonical ID",
+ });
+ }
+}
+
/** Validates the role identity embedded in authorization responses. */
export const roleReferenceSchema = z
- .object({
- id: z.string().min(1),
- key: builtInRoleKeySchema.nullable(),
- name: z.string().min(1),
- })
- .strict();
+ .object(roleReferenceShape)
+ .strict()
+ .superRefine(validateRoleIdentity);
/** Validates an administrative role view with effective grants and assignment count. */
-export const roleSummarySchema = roleReferenceSchema.extend({
- description: z.string().nullable(),
- permissions: z.array(permissionIdSchema),
- assignmentCount: z.number().int().nonnegative(),
-});
+export const roleSummarySchema = z
+ .object({
+ ...roleReferenceShape,
+ description: z.string().nullable(),
+ permissions: z.array(permissionIdSchema),
+ assignmentCount: z.number().int().nonnegative(),
+ })
+ .strict()
+ .superRefine(validateRoleIdentity);
/** Validates a user's role, suspension state, and currently effective permissions. */
export const effectiveAuthorizationSchema = z
@@ -213,6 +246,8 @@ export const replaceMemberStatusInputSchema = z
/** Administrative role data with effective grants and current assignment count. */
export type RoleSummary = z.infer;
+/** A built-in or custom role identity with canonical ID/key pairing. */
+export type RoleReference = z.infer;
/** The authorization state used to make permission decisions for a user. */
export type EffectiveAuthorization = z.infer;
/** A workspace member and their current RBAC assignment state. */
diff --git a/scripts/bootstrap-workspace-owner.test.ts b/scripts/bootstrap-workspace-owner.test.ts
index 58eefd90b..5f9f9eeb3 100644
--- a/scripts/bootstrap-workspace-owner.test.ts
+++ b/scripts/bootstrap-workspace-owner.test.ts
@@ -1,7 +1,8 @@
import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import { DatabaseSync } from "node:sqlite";
-import { buildBootstrapSql, parseArgs } from "./bootstrap-workspace-owner.ts";
+import { buildBootstrapSql, parseArgs, run } from "./bootstrap-workspace-owner.ts";
const USER_ID = "11111111111111111111111111111111";
const OTHER_USER_ID = "22222222222222222222222222222222";
@@ -308,3 +309,68 @@ describe("Owner bootstrap SQL", () => {
assert.match(generated, /SELECT 1 FROM authorization_audit_events WHERE id = 'audit-exact'/);
});
});
+
+describe("Owner bootstrap orchestration", () => {
+ it("accepts only the execution response bound to this invocation's audit", async () => {
+ let calls = 0;
+ await run(
+ { database: "workspace", userId: USER_ID, execute: true },
+ {
+ randomUUID: () => "audit-exact",
+ now: () => 123,
+ runWrangler: (_database, operation) => {
+ calls += 1;
+ if (operation[0] === "--command") {
+ return JSON.stringify([
+ { success: true, results: [{ report: "preflight", status: "ready" }] },
+ ]);
+ }
+ assert.equal(operation[0], "--file");
+ const sqlPath = operation[1];
+ assert.ok(sqlPath);
+ const generated = readFileSync(sqlPath, "utf8");
+ assert.match(generated, /audit-exact/);
+ assert.match(generated, /123/);
+ return JSON.stringify([
+ {
+ success: true,
+ results: [
+ {
+ report: "postcondition",
+ status: "executed",
+ audit_written: 1,
+ },
+ ],
+ },
+ ]);
+ },
+ }
+ );
+
+ assert.equal(calls, 2);
+ });
+
+ it("reports a concurrent winner instead of claiming this invocation completed", async () => {
+ await assert.rejects(
+ run(
+ { database: "workspace", userId: USER_ID, execute: true },
+ {
+ randomUUID: () => "audit-loser",
+ now: () => 123,
+ runWrangler: (_database, operation) =>
+ JSON.stringify([
+ {
+ success: true,
+ results: [
+ operation[0] === "--command"
+ ? { report: "preflight", status: "ready" }
+ : { report: "postcondition", status: "no-op", audit_written: 0 },
+ ],
+ },
+ ]),
+ }
+ ),
+ /ownership changed concurrently/
+ );
+ });
+});
diff --git a/scripts/bootstrap-workspace-owner.ts b/scripts/bootstrap-workspace-owner.ts
index e0a1ac66b..50976d0a6 100644
--- a/scripts/bootstrap-workspace-owner.ts
+++ b/scripts/bootstrap-workspace-owner.ts
@@ -207,6 +207,15 @@ interface WranglerResult {
success?: boolean;
}
+type WranglerRunner = (database: string, operation: readonly string[]) => string;
+
+/** Injectable side effects for deterministic bootstrap orchestration tests. */
+export interface BootstrapRunDependencies {
+ runWrangler?: WranglerRunner;
+ randomUUID?: () => string;
+ now?: () => number;
+}
+
function reportRows(stdout: string): Array> {
const parsed = JSON.parse(stdout) as WranglerResult[];
const rows = parsed.flatMap((result) => result.results ?? []).filter((row) => row.report);
@@ -226,18 +235,22 @@ function runWrangler(database: string, operation: readonly string[]): string {
return child.stdout;
}
-function preflight(database: string, userId: string): string {
+function preflight(database: string, userId: string, runner: WranglerRunner): string {
const sql = buildBootstrapSql({ userId, execute: false, auditId: "unused", now: 0 });
- const rows = reportRows(runWrangler(database, ["--command", sql]));
+ const rows = reportRows(runner(database, ["--command", sql]));
const status = rows.find((row) => row.report === "preflight")?.status;
if (typeof status !== "string") throw new Error("Wrangler returned no Owner bootstrap preflight");
return status;
}
/** Run the remote Owner bootstrap workflow and verify its postcondition. */
-export async function run(options: BootstrapCliOptions): Promise {
+export async function run(
+ options: BootstrapCliOptions,
+ dependencies: BootstrapRunDependencies = {}
+): Promise {
+ const runner = dependencies.runWrangler ?? runWrangler;
console.error(`${options.execute ? "Executing" : "Dry-running"} Owner bootstrap on remote D1...`);
- const status = preflight(options.database, options.userId);
+ const status = preflight(options.database, options.userId, runner);
if (status === "refused") throw new Error("Owner bootstrap preflight was refused");
if (status === "no-op") return;
if (!options.execute) {
@@ -247,24 +260,31 @@ export async function run(options: BootstrapCliOptions): Promise {
const directory = await mkdtemp(join(tmpdir(), "open-inspect-owner-bootstrap-"));
const sqlPath = join(directory, "bootstrap.sql");
+ let executionRows: Array>;
try {
+ const auditId = dependencies.randomUUID?.() ?? crypto.randomUUID();
+ const now = dependencies.now?.() ?? Date.now();
await writeFile(
sqlPath,
buildBootstrapSql({
userId: options.userId,
execute: true,
- auditId: crypto.randomUUID(),
- now: Date.now(),
+ auditId,
+ now,
}),
{ encoding: "utf8", mode: 0o600 }
);
- runWrangler(options.database, ["--file", sqlPath]);
+ executionRows = reportRows(runner(options.database, ["--file", sqlPath]));
} finally {
await rm(directory, { recursive: true, force: true });
}
- if (preflight(options.database, options.userId) !== "no-op") {
- throw new Error("Owner bootstrap postcondition verification failed");
+ const postcondition = executionRows.find((row) => row.report === "postcondition");
+ if (postcondition?.status === "no-op") {
+ throw new Error("Owner bootstrap did not execute because ownership changed concurrently");
+ }
+ if (postcondition?.status !== "executed" || Number(postcondition.audit_written) !== 1) {
+ throw new Error("Owner bootstrap execution did not prove its exact audit and assignment");
}
console.error(
"Owner bootstrap command completed; verify /health reports ownerAssignment=present."
diff --git a/scripts/merge-split-users.ts b/scripts/merge-split-users.ts
index fe1fcee8c..aad4ce82d 100644
--- a/scripts/merge-split-users.ts
+++ b/scripts/merge-split-users.ts
@@ -11,9 +11,8 @@
*
* Dry-run is the default — it prints exact per-table counts and writes
* nothing. Pass --execute to apply. The merge is idempotent: re-running a
- * completed merge is a zero-count no-op, so a partially-applied run (the
- * wrangler transport executes statements sequentially, not atomically) is
- * repaired by running the script again.
+ * completed merge is a zero-count no-op. Execute mode submits the complete
+ * graph mutation as one atomic D1 SQL file.
*
* Usage:
* node --experimental-transform-types scripts/merge-split-users.ts \
@@ -26,6 +25,9 @@
*/
import { spawnSync } from "node:child_process";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import type {
SqlDatabase,
SqlResult,
@@ -109,17 +111,20 @@ class WranglerD1Database implements SqlDatabase {
return statement;
}
- // Deviation from the SqlDatabase.batch contract: all statements go to D1
- // in one wrangler submission, but cross-statement atomicity is not
- // guaranteed by this transport (scripts/d1-migrate.sh documents D1
- // multi-statement submissions as atomic; we deliberately do not rely on
- // it). The merge tolerates this for every statement except the final email
- // backfill, whose input row is deleted earlier in the batch: re-running
- // repairs any other partial application, and the CLI prints a recovery
- // record before executing to cover that one residual case.
+ // D1 executes one --file submission atomically. Keep this adapter aligned
+ // with SqlDatabase.batch rather than emulating a batch through independent
+ // or non-transactional command calls.
async batch(statements: SqlStatement[]): Promise[]> {
+ if (statements.length === 0) return [];
const rendered = statements.map((entry) => (entry as { render(): string }).render());
- return this.execute(rendered).map((result) => toSqlResult(result));
+ const directory = mkdtempSync(join(tmpdir(), "open-inspect-user-merge-"));
+ const sqlPath = join(directory, "merge.sql");
+ try {
+ writeFileSync(sqlPath, `${rendered.join(";\n")};\n`, { encoding: "utf8", mode: 0o600 });
+ return this.executeOperation(["--file", sqlPath]).map((result) => toSqlResult(result));
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
}
private execute(statements: string[]): WranglerQueryResult[] {
@@ -127,6 +132,10 @@ class WranglerD1Database implements SqlDatabase {
if (this.verbose) {
for (const statement of statements) console.error(`[sql] ${statement}`);
}
+ return this.executeOperation(["--command", statements.join(";\n")]);
+ }
+
+ private executeOperation(operation: string[]): WranglerQueryResult[] {
const args = [
"wrangler",
"d1",
@@ -134,8 +143,7 @@ class WranglerD1Database implements SqlDatabase {
this.databaseName,
this.remote ? "--remote" : "--local",
"--json",
- "--command",
- statements.join(";\n"),
+ ...operation,
];
const child = spawnSync("npx", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
if (child.status !== 0) {
@@ -211,25 +219,6 @@ async function main(): Promise {
const options = parseArgs(process.argv.slice(2));
const db = new WranglerD1Database(options.database, !options.local, options.verbose);
- if (options.execute) {
- // Durable recovery record: the final email backfill is the one statement
- // a re-run cannot repair, because its input (the loser row) is deleted by
- // the statement before it. Everything needed to restore that step by hand
- // is printed here, before anything executes.
- const loserRecord = await db
- .prepare(`SELECT id, email, email_verified FROM users WHERE id = ?`)
- .bind(options.loserId)
- .first<{ id: string; email: string | null; email_verified: number }>();
- console.error(`Recovery record (loser row): ${JSON.stringify(loserRecord)}`);
- console.error(
- "Retain this until the merge is verified. If a run fails partway, re-run it — " +
- "that repairs every step except the final email backfill. If the survivor is " +
- "left without the loser's email, restore it manually:\n" +
- ` UPDATE users SET email = , email_verified = ` +
- `WHERE id = '${options.survivorId}' AND email IS NULL;\n`
- );
- }
-
const result = await mergeUsers(db, {
survivorId: options.survivorId,
loserId: options.loserId,
diff --git a/terraform/d1/migrations/0071_rbac_foundation.sql b/terraform/d1/migrations/0071_rbac_foundation.sql
index 4297c3da2..5c093b8dc 100644
--- a/terraform/d1/migrations/0071_rbac_foundation.sql
+++ b/terraform/d1/migrations/0071_rbac_foundation.sql
@@ -8,8 +8,18 @@ CREATE TABLE roles (
description TEXT,
is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0, 1)),
CHECK (
- (is_system = 1 AND key IN ('owner', 'administrator', 'member', 'viewer'))
- OR (is_system = 0 AND key IS NULL)
+ (is_system = 1 AND key IS NOT NULL AND (
+ (id = 'role_builtin_owner' AND key = 'owner')
+ OR (id = 'role_builtin_administrator' AND key = 'administrator')
+ OR (id = 'role_builtin_member' AND key = 'member')
+ OR (id = 'role_builtin_viewer' AND key = 'viewer')
+ ))
+ OR (is_system = 0 AND key IS NULL AND id NOT IN (
+ 'role_builtin_owner',
+ 'role_builtin_administrator',
+ 'role_builtin_member',
+ 'role_builtin_viewer'
+ ))
)
);
From 675a55449cd45760d2e5c74d3315e021b85eddd0 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Sun, 30 Aug 2026 23:02:57 -0700
Subject: [PATCH 09/11] fix(rbac): preserve merge batch result contract
---
.github/workflows/ci.yml | 3 +
package.json | 1 +
packages/control-plane/src/db/user-merge.ts | 4 +-
.../test/integration/user-merge.test.ts | 17 ++++++
scripts/merge-split-users.test.ts | 51 ++++++++++++++++
scripts/merge-split-users.ts | 60 ++++++++++++-------
6 files changed, 111 insertions(+), 25 deletions(-)
create mode 100644 scripts/merge-split-users.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f70ac230d..e62952766 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -82,6 +82,9 @@ jobs:
- name: Test Owner bootstrap CLI
run: npm run test:rbac-bootstrap-owner
+ - name: Test user merge CLI
+ run: npm run test:user-merge-cli
+
- name: Check Prettier formatting
run: npm run format:check
diff --git a/package.json b/package.json
index 5c7a63432..5e6c2bbee 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"test": "npm run test --workspaces --if-present",
"test:lint-complexity": "node --test scripts/lint-complexity-message.test.mjs",
"test:rbac-bootstrap-owner": "node --experimental-transform-types --test scripts/bootstrap-workspace-owner.test.ts",
+ "test:user-merge-cli": "node --experimental-transform-types --test scripts/merge-split-users.test.ts",
"test:coverage": "npm run test:coverage --workspaces --if-present",
"test:integration": "npm run test:integration --workspaces --if-present",
"typecheck": "npm run build -w @open-inspect/shared && npm run typecheck --workspaces --if-present",
diff --git a/packages/control-plane/src/db/user-merge.ts b/packages/control-plane/src/db/user-merge.ts
index 95e3d190c..da626a47d 100644
--- a/packages/control-plane/src/db/user-merge.ts
+++ b/packages/control-plane/src/db/user-merge.ts
@@ -319,7 +319,7 @@ export async function mergeUsers(
if (survivorRole && loserRole && survivorRole.role_id !== loserRole.role_id) {
throw new UserMergeError("Resolve conflicting user roles before merging");
}
- if (survivor.suspended_at !== loser.suspended_at) {
+ if ((survivor.suspended_at === null) !== (loser.suspended_at === null)) {
throw new UserMergeError("Resolve conflicting user suspension states before merging");
}
if (loserRole?.role_key === "owner" && survivor.suspended_at !== null) {
@@ -377,7 +377,7 @@ export async function mergeUsers(
JOIN roles role ON role.id = loser_assignment.role_id
WHERE survivor.id = ?
AND survivor_assignment.role_id = loser_assignment.role_id
- AND survivor.suspended_at IS loser.suspended_at
+ AND (survivor.suspended_at IS NULL) = (loser.suspended_at IS NULL)
AND (role.key IS NULL OR role.key <> 'owner' OR survivor.suspended_at IS NULL)
) THEN ? ELSE NULL END,
'user-merge', 'service', 'control-plane',
diff --git a/packages/control-plane/test/integration/user-merge.test.ts b/packages/control-plane/test/integration/user-merge.test.ts
index 75e8d66ee..1821b115f 100644
--- a/packages/control-plane/test/integration/user-merge.test.ts
+++ b/packages/control-plane/test/integration/user-merge.test.ts
@@ -455,6 +455,23 @@ describe("mergeUsers", () => {
expect(await getUserRow(LOSER)).not.toBeNull();
});
+ it("merges two suspended users even when their suspension timestamps differ", async () => {
+ await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
+ await insertCanonicalUser({ id: LOSER, email: null });
+ await env.DB.prepare("UPDATE users SET suspended_at = 123 WHERE id = ?").bind(SURVIVOR).run();
+ await env.DB.prepare("UPDATE users SET suspended_at = 456 WHERE id = ?").bind(LOSER).run();
+
+ await expect(
+ mergeUsers(env.DB, { survivorId: SURVIVOR, loserId: LOSER })
+ ).resolves.toMatchObject({
+ counts: { usersDeleted: 1 },
+ });
+ expect(await getUserRow(LOSER)).toBeNull();
+ expect(
+ await env.DB.prepare("SELECT suspended_at FROM users WHERE id = ?").bind(SURVIVOR).first()
+ ).toEqual({ suspended_at: 123 });
+ });
+
it("rolls back when role invariants change after preflight", async () => {
await insertCanonicalUser({ id: SURVIVOR, email: "person@example.com" });
await insertCanonicalUser({ id: LOSER, email: null });
diff --git a/scripts/merge-split-users.test.ts b/scripts/merge-split-users.test.ts
new file mode 100644
index 000000000..e5e4c1f3a
--- /dev/null
+++ b/scripts/merge-split-users.test.ts
@@ -0,0 +1,51 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { WranglerD1Database, type WranglerRunner } from "./merge-split-users.ts";
+
+function result(results: Record[], changes = 0): string {
+ return JSON.stringify([{ success: true, results, meta: { changes } }]);
+}
+
+describe("Wrangler user-merge database adapter", () => {
+ it("uses the result-bearing command batch and preserves positional results", async () => {
+ let invokedArgs: string[] = [];
+ const runner: WranglerRunner = (args) => {
+ invokedArgs = args;
+ return {
+ status: 0,
+ stderr: "",
+ stdout: JSON.stringify([
+ { success: true, results: [{ role_id: "survivor-role" }], meta: { changes: 0 } },
+ { success: true, results: [{ role_id: "loser-role" }], meta: { changes: 0 } },
+ ]),
+ };
+ };
+ const database = new WranglerD1Database("workspace", true, false, runner);
+
+ const results = await database.batch([
+ database.prepare("SELECT role_id FROM assignments WHERE user_id = ?").bind("survivor"),
+ database.prepare("SELECT role_id FROM assignments WHERE user_id = ?").bind("loser"),
+ ]);
+
+ assert.deepEqual(
+ results.map((entry) => entry.results[0]),
+ [{ role_id: "survivor-role" }, { role_id: "loser-role" }]
+ );
+ assert.ok(invokedArgs.includes("--command"));
+ assert.ok(!invokedArgs.includes("--file"));
+ });
+
+ it("fails loudly if Wrangler collapses a batch into one aggregate result", async () => {
+ const runner: WranglerRunner = () => ({
+ status: 0,
+ stderr: "",
+ stdout: result([{ "Total queries executed": 2 }]),
+ });
+ const database = new WranglerD1Database("workspace", true, false, runner);
+
+ await assert.rejects(
+ database.batch([database.prepare("SELECT 1"), database.prepare("SELECT 2")]),
+ /returned 1 results for 2 batched statements/
+ );
+ });
+});
diff --git a/scripts/merge-split-users.ts b/scripts/merge-split-users.ts
index aad4ce82d..63306815f 100644
--- a/scripts/merge-split-users.ts
+++ b/scripts/merge-split-users.ts
@@ -12,7 +12,7 @@
* Dry-run is the default — it prints exact per-table counts and writes
* nothing. Pass --execute to apply. The merge is idempotent: re-running a
* completed merge is a zero-count no-op. Execute mode submits the complete
- * graph mutation as one atomic D1 SQL file.
+ * graph mutation as one result-bearing D1 batch.
*
* Usage:
* node --experimental-transform-types scripts/merge-split-users.ts \
@@ -25,9 +25,8 @@
*/
import { spawnSync } from "node:child_process";
-import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
+import { resolve } from "node:path";
+import { pathToFileURL } from "node:url";
import type {
SqlDatabase,
SqlResult,
@@ -45,6 +44,19 @@ interface WranglerQueryResult {
meta?: { changes?: number };
}
+/** Minimal process result used to test Wrangler orchestration without spawning. */
+export interface WranglerProcessResult {
+ status: number | null;
+ stdout: string;
+ stderr: string;
+}
+
+/** Injectable runner for Wrangler CLI orchestration tests. */
+export type WranglerRunner = (args: string[]) => WranglerProcessResult;
+
+const runWrangler: WranglerRunner = (args) =>
+ spawnSync("npx", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
+
function sqlLiteral(value: unknown): string {
if (value === null || value === undefined) return "NULL";
if (typeof value === "number") {
@@ -76,11 +88,12 @@ function inlineParams(sql: string, params: unknown[]): string {
return rendered;
}
-class WranglerD1Database implements SqlDatabase {
+export class WranglerD1Database implements SqlDatabase {
constructor(
private readonly databaseName: string,
private readonly remote: boolean,
- private readonly verbose: boolean
+ private readonly verbose: boolean,
+ private readonly runner: WranglerRunner = runWrangler
) {}
prepare(query: string): SqlStatement {
@@ -111,20 +124,18 @@ class WranglerD1Database implements SqlDatabase {
return statement;
}
- // D1 executes one --file submission atomically. Keep this adapter aligned
- // with SqlDatabase.batch rather than emulating a batch through independent
- // or non-transactional command calls.
+ // Remote --command sends semicolon-separated statements to D1's /query
+ // batch API. D1 executes the batch transactionally and Wrangler preserves
+ // one positional result (including meta.changes) per statement.
async batch(statements: SqlStatement[]): Promise[]> {
- if (statements.length === 0) return [];
const rendered = statements.map((entry) => (entry as { render(): string }).render());
- const directory = mkdtempSync(join(tmpdir(), "open-inspect-user-merge-"));
- const sqlPath = join(directory, "merge.sql");
- try {
- writeFileSync(sqlPath, `${rendered.join(";\n")};\n`, { encoding: "utf8", mode: 0o600 });
- return this.executeOperation(["--file", sqlPath]).map((result) => toSqlResult(result));
- } finally {
- rmSync(directory, { recursive: true, force: true });
+ const results = this.execute(rendered);
+ if (results.length !== statements.length) {
+ throw new Error(
+ `Wrangler returned ${results.length} results for ${statements.length} batched statements`
+ );
}
+ return results.map((result) => toSqlResult(result));
}
private execute(statements: string[]): WranglerQueryResult[] {
@@ -145,7 +156,7 @@ class WranglerD1Database implements SqlDatabase {
"--json",
...operation,
];
- const child = spawnSync("npx", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
+ const child = this.runner(args);
if (child.status !== 0) {
throw new Error(`wrangler d1 execute failed:\n${child.stderr || child.stdout}`);
}
@@ -247,8 +258,11 @@ async function main(): Promise {
}
}
-main().catch((error: unknown) => {
- const message = error instanceof UserMergeError ? error.message : String(error);
- console.error(message);
- process.exitCode = 1;
-});
+const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null;
+if (invokedPath === import.meta.url) {
+ main().catch((error: unknown) => {
+ const message = error instanceof UserMergeError ? error.message : String(error);
+ console.error(message);
+ process.exitCode = 1;
+ });
+}
From 82f72644d5534d54693cd51e0888d25c6c097eb9 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Sun, 30 Aug 2026 21:56:45 -0700
Subject: [PATCH 10/11] feat: gate session and automation UI by permission
---
README.md | 8 +-
docs/AUTH.md | 206 +++++
docs/GETTING_STARTED.md | 62 +-
.../automations/[id]/edit/page.test.tsx | 92 ++
.../(sidebar)/automations/[id]/edit/page.tsx | 18 +-
.../(sidebar)/automations/[id]/page.test.tsx | 107 +++
.../(app)/(sidebar)/automations/[id]/page.tsx | 106 ++-
.../(sidebar)/automations/new/page.test.tsx | 21 +-
.../(app)/(sidebar)/automations/new/page.tsx | 11 +-
.../(app)/(sidebar)/automations/page.test.tsx | 29 +-
.../app/(app)/(sidebar)/automations/page.tsx | 27 +-
.../automations/templates/page.test.tsx | 54 ++
.../(sidebar)/automations/templates/page.tsx | 12 +
.../web/src/app/(app)/(sidebar)/page.test.tsx | 20 +
packages/web/src/app/(app)/(sidebar)/page.tsx | 16 +-
.../app/(app)/(sidebar)/session/[id]/page.tsx | 109 ++-
.../web/src/components/action-bar.test.tsx | 14 +
packages/web/src/components/action-bar.tsx | 35 +-
.../automations/automations-list.test.tsx | 81 +-
.../automations/automations-list.tsx | 229 ++---
.../web/src/components/diff-retry-notice.tsx | 26 +-
.../src/components/mobile-session-actions.tsx | 31 +-
.../components/queued-prompt-stack.test.tsx | 13 +
.../src/components/queued-prompt-stack.tsx | 24 +-
.../web/src/components/session-actions.ts | 1 +
.../src/components/session-changes-panel.tsx | 9 +-
.../components/session-details-overlay.tsx | 6 +
.../src/components/session-header.test.tsx | 30 +
.../web/src/components/session-header.tsx | 18 +-
.../src/components/session-list-item.test.tsx | 95 ++
.../web/src/components/session-list-item.tsx | 115 +--
.../components/session-prompt-composer.tsx | 2 +
.../components/session-right-sidebar.test.tsx | 39 +
.../src/components/session-right-sidebar.tsx | 38 +-
.../web/src/components/session-sidebar.tsx | 1 +
.../src/components/sidebar-layout.test.tsx | 22 +
.../web/src/components/sidebar-layout.tsx | 9 +-
.../components/sidebar/metadata-section.tsx | 4 +-
.../src/hooks/use-global-shortcuts.test.tsx | 37 +-
.../web/src/hooks/use-global-shortcuts.ts | 6 +-
packages/web/src/hooks/use-sandbox-access.ts | 9 +-
.../web/src/hooks/use-session-socket.test.tsx | 22 +
packages/web/src/hooks/use-session-socket.ts | 24 +-
.../src/hooks/use-session-transport.test.tsx | 19 +
.../web/src/hooks/use-session-transport.ts | 28 +-
.../src/lib/automation-authorization.test.ts | 45 +
.../web/src/lib/automation-authorization.ts | 17 +
.../docs/internal/2026-08-28-rbac-design.md | 815 ++++++++++++++++++
.../docs/internal/2026-08-28-rbac-research.md | 386 +++++++++
.../2026-08-30-session-access-research.md | 407 +++++++++
...space-wide-session-authorization-design.md | 193 +++++
51 files changed, 3391 insertions(+), 357 deletions(-)
create mode 100644 docs/AUTH.md
create mode 100644 packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx
create mode 100644 packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx
create mode 100644 packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx
create mode 100644 packages/web/src/components/session-list-item.test.tsx
create mode 100644 packages/web/src/lib/automation-authorization.test.ts
create mode 100644 packages/web/src/lib/automation-authorization.ts
create mode 100644 public/docs/internal/2026-08-28-rbac-design.md
create mode 100644 public/docs/internal/2026-08-28-rbac-research.md
create mode 100644 public/docs/internal/2026-08-30-session-access-research.md
create mode 100644 public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md
diff --git a/README.md b/README.md
index 304c79a66..58c915e48 100644
--- a/README.md
+++ b/README.md
@@ -29,8 +29,9 @@ The system uses a shared GitHub App installation for git operations (clone, fetc
control plane mints short-lived installation tokens server-side and brokers them to sandboxes
through the git credential helper on demand. This means:
-- **All users share the same GitHub App credentials** - The GitHub App must be installed on your
- organization's repositories, and any user of the system can access any repo the App has access to
+- **Authorized users share the same GitHub App credentials** - The GitHub App must be installed on
+ your organization's repositories, and active users whose role permits repository use can access
+ any repo the App has access to
- **No per-user repository access validation** - The system does not verify that a user has
permission to access a specific repository before creating a session
- **GitHub users' OAuth tokens are used for PR creation** - For GitHub logins, PRs are created using
@@ -70,6 +71,9 @@ built for internal use where all employees are trusted and have access to compan
4. **Use GitHub's repository selection** - When installing the App, select specific repositories
rather than "All repositories"
+See [Authentication and Authorization](docs/AUTH.md) for workspace roles, session access, automation
+ownership, bots, and member suspension.
+
## Architecture
```
diff --git a/docs/AUTH.md b/docs/AUTH.md
new file mode 100644
index 000000000..e8d9f4fc8
--- /dev/null
+++ b/docs/AUTH.md
@@ -0,0 +1,206 @@
+# Authentication and Authorization
+
+Open-Inspect uses authentication to establish who you are and workspace authorization to decide what
+you can do. This guide explains the behavior users and workspace administrators will see.
+
+> **Important:** Open-Inspect is designed for a single trusted organization. A deployment is one
+> workspace, and the source-control App installation defines the repositories available to that
+> workspace. Roles control which Open-Inspect features a person can use; they are not per-repository
+> access lists.
+
+---
+
+## Signing In
+
+A deployment can offer GitHub sign-in, Google sign-in, or both. The sign-in page shows only the
+providers configured by the deployment operator.
+
+Signing in has two stages:
+
+1. Your identity provider verifies your identity and email address.
+2. The deployment's admission rules determine whether you may join the workspace.
+
+Depending on the deployment configuration, admission can be limited by:
+
+- GitHub username
+- Verified email address
+- Verified email domain
+- Active membership in an allowed GitHub organization
+
+These rules are checked when you sign in. Removing someone from an allowlist or GitHub organization
+does not end an existing browser session; an Administrator or Owner can suspend the member when
+access must be revoked immediately.
+
+Authentication does not make someone an Owner or Administrator. Every admitted user has exactly one
+workspace role, and new users receive the Member role by default.
+
+## Workspace Roles
+
+Open-Inspect includes four built-in roles.
+
+| Capability | Owner | Administrator | Member | Viewer |
+| ------------------------------------------------- | :---: | :-----------: | :----: | :----: |
+| View repositories and environments | Yes | Yes | Yes | Yes |
+| Use repositories and environments in sessions | Yes | Yes | Yes | No |
+| Manage shared settings, integrations, and secrets | Yes | Yes | No | No |
+| Create sessions | Yes | Yes | Yes | No |
+| View every session | Yes | Yes | Yes | Yes |
+| Collaborate in and manage sessions | Yes | Yes | Yes | No |
+| View automations | Yes | Yes | Yes | Yes |
+| Create automations | Yes | Yes | Yes | No |
+| Manage and trigger own automations | Yes | Yes | Yes | No |
+| Manage and trigger any automation | Yes | Yes | No | No |
+| View and manage workspace members | Yes | Yes | No | No |
+| Transfer workspace ownership | Yes | No | No | No |
+| View analytics | Yes | Yes | Yes | Yes |
+| View provider accounts | Yes | Yes | Yes | No |
+| View image-build history | Yes | Yes | Yes | Yes |
+| Manage personal skill profiles | Yes | Yes | Yes | No |
+
+### Owner
+
+Owners have full access to the workspace. Only Owners can grant or remove the Owner role or suspend
+and restore another Owner. Open-Inspect also prevents the final active Owner from being suspended or
+demoted, so the workspace cannot accidentally lose all ownership.
+
+### Administrator
+
+Administrators can operate the workspace day to day. They can manage members, sessions, automations,
+repositories, environments, provider accounts, integrations, and secrets. They cannot transfer
+ownership, change who holds the Owner role, or suspend and restore an Owner.
+
+### Member
+
+Members can create and use sessions, collaborate in existing sessions, use shared repositories and
+environments, and create automations. They can manage and manually trigger automations they own but
+cannot modify another person's automation or administer shared configuration. They can view
+workspace analytics.
+
+### Viewer
+
+Viewers have read-only access to shared workspace resources. They can inspect sessions, automations,
+analytics, repositories, environments, skills, and MCP servers. They cannot create or prompt
+sessions, access sandboxes, manage personal skill profiles, trigger automations, or change shared
+configuration.
+
+## How Session Access Works
+
+Sessions are workspace resources rather than private resources owned by their creator.
+
+- Anyone with session read access can view every session in the workspace.
+- Anyone with collaboration access can prompt and contribute to every session.
+- Anyone with lifecycle access can stop, retry, archive, unarchive, and otherwise manage every
+ session.
+- Anyone with sandbox access can use supported sandbox tools for every session.
+- Anyone with delete access can delete every session.
+
+The creator shown on a session records attribution; it is not an access list. Likewise, participant
+labels identify who contributed to a session but do not grant or remove workspace permissions. The
+**Mine** filter is a convenience for finding sessions you created, not a security boundary.
+
+Creating a session also requires permission to use its selected repository or environment. A role
+may therefore be able to view an existing session without being allowed to create a new one.
+
+New HTTP requests reflect role changes and suspension immediately. Live browser connections to a
+session are rechecked at least every five minutes, so a connection may remain open for up to five
+minutes after access changes. Recreating the session is not required.
+
+## How Automation Access Works
+
+Automation definitions and run history are visible workspace-wide to roles with automation read
+access. Creating, changing, and manually triggering automations use ownership rules.
+
+- Members can manage and manually trigger automations they own.
+- Administrators and Owners can manage and manually trigger any automation.
+- Viewers can inspect automations but cannot create, change, or run them.
+
+Automation ownership follows the signed-in account that created it, not a display name or external
+provider username.
+
+### Scheduled and Event Runs
+
+Scheduled and event-driven runs execute under the automation owner's authority. At run time, the
+owner must still be active and allowed to create sessions and use every selected repository or
+environment. If those permissions have been removed, the run does not start.
+
+### Manual Runs
+
+A manual run executes under the authority of the person who clicked **Run**, even when an
+Administrator or Owner triggers someone else's automation. The requester must be allowed both to
+trigger that automation and to create the resulting session with its selected resources. Their
+identity and linked source-control credentials are used for that run.
+
+See [Automations](AUTOMATIONS.md) for trigger setup and run behavior.
+
+## Bots and Integrations
+
+Slack, GitHub, and Linear integrations act on behalf of a workspace user when they handle a user
+request. Their effective access is limited by both:
+
+- The acting user's current role
+- The integration's fixed set of allowed operations
+
+This means an integration cannot bypass a suspended user or perform workspace administration simply
+because the acting user is an Owner. Calls that do not identify an acting user are denied unless a
+specific integration route explicitly permits that operation.
+
+Some integrations also apply their own ingress rules. For example, the GitHub integration may
+require an allowed trigger user or sufficient repository collaborator access before it sends a
+request to Open-Inspect.
+
+## Suspension
+
+Suspending a member disables their workspace access without deleting their account or historical
+attribution.
+
+After suspension:
+
+- New browser and bot operations are denied.
+- Existing browser sign-in sessions are invalidated.
+- Live browser session connections close within five minutes.
+- Scheduled and event-driven automations owned by the member no longer pass run authorization.
+- Existing session history and authorship remain intact.
+
+Suspension does not automatically stop a sandbox that is already executing. An Administrator or
+Owner can manage that session separately.
+
+## Repository and Credential Boundaries
+
+Open-Inspect uses a shared source-control App installation for clone, fetch, and push operations.
+The App should be installed only on repositories intended for the workspace.
+
+A user's role determines whether they may read or use workspace repositories, but Open-Inspect does
+not compare that role with the user's personal GitHub access for each repository. Linked GitHub
+credentials can be used for actions such as attributed pull-request creation; when no suitable user
+credential is available, supported operations may use the shared App identity.
+
+Secrets and provider credentials are not made visible through role-based read access. Administrative
+permissions control who can configure them, and saved secret values are not returned to the browser.
+See [Secrets Management](SECRETS.md) for details.
+
+## Workspace Administration
+
+Owners and Administrators can manage members from **Settings > Workspace access**. Depending on
+their own role, they can:
+
+- Review workspace members and assigned roles
+- Change a member's role
+- Suspend or restore a member
+
+Only an Owner can assign or remove the Owner role or suspend and restore another Owner. The final
+active Owner cannot be suspended or demoted.
+
+### Initial Owner Setup
+
+The first person who signs in receives the default Member role and is not promoted to Owner
+automatically. On a new deployment, the intended Owner must sign in once, after which a deployment
+operator runs the Owner bootstrap command using that person's Open-Inspect user ID. See
+[Getting Started](GETTING_STARTED.md#step-7a-bootstrap-the-workspace-owner) for the deployment
+steps.
+
+## Related Guides
+
+- [Getting Started](GETTING_STARTED.md)
+- [Automations](AUTOMATIONS.md)
+- [Secrets Management](SECRETS.md)
+- [How Open-Inspect Works](HOW_IT_WORKS.md)
diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md
index d9c2402dc..9600c52d6 100644
--- a/docs/GETTING_STARTED.md
+++ b/docs/GETTING_STARTED.md
@@ -302,10 +302,11 @@ GitHub OAuth sign-in, but its client pair is optional when Google is the only si
> **Keep "User-to-server token expiration" active** (GitHub App → **Optional Features**; it is
> the default for newly created Apps, but activate it if yours predates that default). Expiring
> user tokens are what make GitHub return a **refresh token** at sign-in, and Open-Inspect stores
- > that per-user credential so sessions clone, commit, and push **as the signed-in user**. With
- > expiration deactivated — or on an **OAuth App**, which never issues a refresh token — no
- > per-user credential is captured and sessions fall back to the shared GitHub App **bot**
- > identity for repository access.
+ > that per-user credential for attributed GitHub operations such as pull-request creation. Clone,
+ > fetch, and push authentication still use the shared GitHub App installation. With expiration
+ > deactivated — or on an **OAuth App**, which never issues a refresh token — no per-user
+ > credential is captured, so supported attributed operations fall back to the shared GitHub App
+ > **bot** identity.
5. Set **Repository permissions**:
- Actions: **Read-only** _(required for GitHub workflow-run automations)_
@@ -651,10 +652,9 @@ configurations because they authorize repository operations; they do not enable
### Enable Google Login (Optional)
-Google login lets non-developer users (PMs, support agents) sign in without a GitHub account. They
-get the same flat access as everyone else; git operations still use the shared GitHub App, and their
-PRs fall back to the App bot (no personal GitHub attribution unless the same verified email is also
-a linked GitHub identity).
+Google login lets non-developer users (PMs, support agents) sign in without a GitHub account. Git
+operations still use the shared GitHub App, and their PRs fall back to the App bot (no personal
+GitHub attribution unless the same verified email is also a linked GitHub identity).
1. In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), create an
**OAuth client ID** of type **Web application**.
@@ -726,6 +726,52 @@ Terraform will update the workers with the required bindings.
---
+## Step 7a: Bootstrap the Workspace Owner
+
+Owner assignment is an explicit operator action. After both deployment phases complete:
+
+1. Have the intended Owner sign in to the deployed web application once. This creates their
+ canonical user and default role assignment.
+2. While signed in, open `/api/auth/get-session` on the web application origin and record the
+ 32-character lowercase hexadecimal `user.id`. The bootstrap command accepts this canonical ID,
+ never an email address.
+3. Obtain the D1 database name with `terraform output -raw d1_database_name` from
+ `terraform/environments/production`.
+4. From the repository root, run the remote dry run (the default):
+
+```bash
+npm run rbac:bootstrap-owner -- \
+ --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \
+ --user ""
+```
+
+5. Confirm the preflight result is `ready` (or `no-op` when the target is already the current
+ unsuspended Owner), then execute the same command with `--execute`:
+
+```bash
+npm run rbac:bootstrap-owner -- \
+ --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \
+ --user "" \
+ --execute
+```
+
+The command uses Wrangler credentials (`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, or
+`wrangler login`) and targets remote D1. It refuses a suspended/missing user, a missing or ambiguous
+assignment, or another unsuspended Owner. There is no force option. Execution is one atomic Wrangler
+SQL file: it writes one redacted `workspace.owner_bootstrapped` service audit event and replaces the
+target's assignment. A no-op writes nothing.
+
+6. Verify the control-plane health response contains `"rbac":{"ownerAssignment":"present"}`:
+
+```bash
+curl "$(terraform -chdir=terraform/environments/production output -raw control_plane_url)/health"
+```
+
+This health value reports current state: `present` means at least one Owner assignment belongs to an
+unsuspended user.
+
+---
+
## Step 7b: Complete Slack Setup (If Using Slack)
Now that the Slack bot worker is deployed, configure the agent experience, App Home, and event
diff --git a/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx
new file mode 100644
index 000000000..cf08e8764
--- /dev/null
+++ b/packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx
@@ -0,0 +1,92 @@
+// @vitest-environment jsdom
+///
+
+import { Suspense } from "react";
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
+import * as matchers from "@testing-library/jest-dom/matchers";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import EditAutomationPage from "./page";
+
+expect.extend(matchers);
+
+const CURRENT_USER_ID = "11111111111111111111111111111111";
+let permissions: string[] = [];
+const replace = vi.fn();
+
+const automation = {
+ id: "auto-1",
+ name: "Nightly review",
+ instructions: "Review the code",
+ triggerType: "schedule" as const,
+ scheduleCron: "0 9 * * *",
+ scheduleTz: "UTC",
+ model: "anthropic/claude-sonnet-4-6",
+ reasoningEffort: null,
+ enabled: true,
+ nextRunAt: null,
+ consecutiveFailures: 0,
+ createdBy: CURRENT_USER_ID,
+ userId: "22222222222222222222222222222222",
+ createdAt: 1,
+ updatedAt: 1,
+ deletedAt: null,
+ eventType: null,
+ triggerConfig: null,
+ repositories: [],
+ environmentIds: [],
+ providerSelections: {},
+};
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace }),
+}));
+vi.mock("@/components/sidebar-layout", () => ({
+ CollapsedSidebarControls: () => null,
+ useSidebarContext: () => ({ isOpen: true }),
+}));
+vi.mock("@/hooks/use-automations", () => ({
+ useAutomation: () => ({ automation, loading: false }),
+}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ authorization: { userId: CURRENT_USER_ID, permissions },
+ loading: false,
+ }),
+}));
+vi.mock("@/components/automations/automation-form", () => ({
+ AutomationForm: () =>
);
}
export function SidebarLayout({ children }: SidebarLayoutProps) {
const router = useRouter();
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canCreateSession = hasPermission("sessions.create");
const sidebar = useSidebar();
const isMobile = useIsMobile();
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
@@ -95,12 +99,13 @@ export function SidebarLayout({ children }: SidebarLayoutProps) {
);
const handleNewSession = useCallback(() => {
+ if (!canCreateSession) return;
setIsCommandMenuOpen(false);
if (isMobile) {
sidebar.close();
}
router.push("/");
- }, [isMobile, router, sidebar]);
+ }, [canCreateSession, isMobile, router, sidebar]);
const handleNavigate = useCallback(
(href: string) => {
diff --git a/packages/web/src/components/sidebar/metadata-section.tsx b/packages/web/src/components/sidebar/metadata-section.tsx
index 9b16187a0..4d56aa061 100644
--- a/packages/web/src/components/sidebar/metadata-section.tsx
+++ b/packages/web/src/components/sidebar/metadata-section.tsx
@@ -52,6 +52,7 @@ interface MetadataSectionProps {
warnings?: WarningEvent[];
parentSessionId?: string | null;
totalCost?: number;
+ canManageLifecycle?: boolean;
}
/**
@@ -108,12 +109,13 @@ export function MetadataSection({
warnings = [],
parentSessionId,
totalCost,
+ canManageLifecycle = true,
}: MetadataSectionProps) {
const [copied, setCopied] = useState(false);
const isMultiRepo = (repositories?.length ?? 0) > 1;
const hasPrArtifact = artifacts.some((a) => a.type === "pr");
- const showSyncButton = Boolean(sessionId) && hasPrArtifact;
+ const showSyncButton = canManageLifecycle && Boolean(sessionId) && hasPrArtifact;
// Sessions can hold several PRs (one open PR per head branch); list them
// all, oldest first — creation order matches PR-number order.
diff --git a/packages/web/src/hooks/use-global-shortcuts.test.tsx b/packages/web/src/hooks/use-global-shortcuts.test.tsx
index 68b8b24ef..3b0507bcc 100644
--- a/packages/web/src/hooks/use-global-shortcuts.test.tsx
+++ b/packages/web/src/hooks/use-global-shortcuts.test.tsx
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_KEYBOARD_SHORTCUTS } from "@open-inspect/shared/types/keyboard-shortcuts";
import { useGlobalShortcuts } from "./use-global-shortcuts";
+const mocks = vi.hoisted(() => ({ canCreateSession: true }));
+
const shortcuts = {
...DEFAULT_KEYBOARD_SHORTCUTS,
"open-command-menu": { code: "KeyP", primary: true, alt: false, shift: false },
@@ -16,8 +18,18 @@ vi.mock("@/hooks/use-keyboard-shortcuts", () => ({
useKeyboardShortcuts: () => ({ shortcuts }),
}));
+vi.mock("@/hooks/use-current-user-authorization", () => ({
+ useCurrentUserAuthorization: () => ({
+ hasPermission: (permission: string) =>
+ permission === "sessions.create" && mocks.canCreateSession,
+ }),
+}));
+
describe("useGlobalShortcuts", () => {
- afterEach(() => vi.restoreAllMocks());
+ afterEach(() => {
+ mocks.canCreateSession = true;
+ vi.restoreAllMocks();
+ });
it("dispatches the configured action and removes its listener", () => {
const onOpenCommandMenu = vi.fn();
@@ -46,4 +58,27 @@ describe("useGlobalShortcuts", () => {
window.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyP", ctrlKey: true }));
expect(onOpenCommandMenu).toHaveBeenCalledOnce();
});
+
+ it("ignores the new session shortcut without session creation permission", () => {
+ mocks.canCreateSession = false;
+ const onNewSession = vi.fn();
+ renderHook(() =>
+ useGlobalShortcuts({
+ onOpenCommandMenu: vi.fn(),
+ onNewSession,
+ onToggleSidebar: vi.fn(),
+ })
+ );
+
+ const event = new KeyboardEvent("keydown", {
+ code: "KeyN",
+ ctrlKey: true,
+ shiftKey: true,
+ cancelable: true,
+ });
+ window.dispatchEvent(event);
+
+ expect(onNewSession).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(false);
+ });
});
diff --git a/packages/web/src/hooks/use-global-shortcuts.ts b/packages/web/src/hooks/use-global-shortcuts.ts
index 33446518c..2f5d41355 100644
--- a/packages/web/src/hooks/use-global-shortcuts.ts
+++ b/packages/web/src/hooks/use-global-shortcuts.ts
@@ -3,6 +3,7 @@
import { useEffect } from "react";
import { matchGlobalShortcut, shouldIgnoreGlobalShortcutForAction } from "@/lib/keyboard-shortcuts";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
+import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
interface UseGlobalShortcutsOptions {
enabled?: boolean;
@@ -18,6 +19,8 @@ export function useGlobalShortcuts({
onToggleSidebar,
}: UseGlobalShortcutsOptions) {
const { shortcuts } = useKeyboardShortcuts();
+ const { hasPermission } = useCurrentUserAuthorization();
+ const canCreateSession = hasPermission("sessions.create");
useEffect(() => {
if (!enabled) return;
@@ -25,6 +28,7 @@ export function useGlobalShortcuts({
const action = matchGlobalShortcut(event, shortcuts);
if (!action) return;
if (shouldIgnoreGlobalShortcutForAction(event, action)) return;
+ if (action === "new-session" && !canCreateSession) return;
event.preventDefault();
@@ -35,5 +39,5 @@ export function useGlobalShortcuts({
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
- }, [enabled, onNewSession, onOpenCommandMenu, onToggleSidebar, shortcuts]);
+ }, [canCreateSession, enabled, onNewSession, onOpenCommandMenu, onToggleSidebar, shortcuts]);
}
diff --git a/packages/web/src/hooks/use-sandbox-access.ts b/packages/web/src/hooks/use-sandbox-access.ts
index d28b3a1d5..28c943041 100644
--- a/packages/web/src/hooks/use-sandbox-access.ts
+++ b/packages/web/src/hooks/use-sandbox-access.ts
@@ -22,10 +22,11 @@ const sandboxAccessSchema = z
type SandboxAccess = z.infer;
-export function useSandboxAccess(sessionId: string, isSandboxReady: boolean) {
- const key: BrowserApiPath | null = isSandboxReady
- ? `/api/sessions/${encodeURIComponent(sessionId)}/sandbox-access`
- : null;
+export function useSandboxAccess(sessionId: string, isSandboxReady: boolean, enabled = true) {
+ const key: BrowserApiPath | null =
+ enabled && isSandboxReady
+ ? `/api/sessions/${encodeURIComponent(sessionId)}/sandbox-access`
+ : null;
const { data, mutate } = useSWR(key, async (url: BrowserApiPath) => {
const response = await browserApiFetch(url, { cache: "no-store" });
if (response.status === 204 || response.status === 404) return null;
diff --git a/packages/web/src/hooks/use-session-socket.test.tsx b/packages/web/src/hooks/use-session-socket.test.tsx
index 477625509..d08cf333f 100644
--- a/packages/web/src/hooks/use-session-socket.test.tsx
+++ b/packages/web/src/hooks/use-session-socket.test.tsx
@@ -139,6 +139,28 @@ describe("useSessionSocket", () => {
vi.restoreAllMocks();
});
+ it("keeps the HTTP snapshot available without collaboration or sandbox requests", async () => {
+ const fetchMock = vi.mocked(fetch);
+ const snapshot = createSnapshot();
+ snapshot.session.title = "Read-only snapshot";
+
+ const { result } = renderHook(() =>
+ useSessionSocket("session-1", snapshot, {
+ collaborate: false,
+ sandboxAccess: false,
+ })
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(result.current.sessionState?.title).toBe("Read-only snapshot");
+ expect(result.current.connected).toBe(false);
+ expect(FakeWebSocket.instances).toHaveLength(0);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
it("keeps sendPrompt pending until the server acknowledges the queued prompt", async () => {
const { result } = renderHook(() => useSessionSocket("session-1", createSnapshot()));
diff --git a/packages/web/src/hooks/use-session-socket.ts b/packages/web/src/hooks/use-session-socket.ts
index 72ef39868..ec9d622c6 100644
--- a/packages/web/src/hooks/use-session-socket.ts
+++ b/packages/web/src/hooks/use-session-socket.ts
@@ -99,7 +99,11 @@ interface PendingCorrelatedRequest {
*/
export function useSessionSocket(
sessionId: string,
- initialSnapshot: SessionSnapshot
+ initialSnapshot: SessionSnapshot,
+ capabilities: { collaborate: boolean; sandboxAccess: boolean } = {
+ collaborate: true,
+ sandboxAccess: true,
+ }
): UseSessionSocketReturn {
const [state, dispatch] = useReducer(
sessionSocketReducer,
@@ -117,7 +121,11 @@ export function useSessionSocket(
sandboxAccess,
clear: clearSandboxAccess,
refresh: refreshSandboxAccess,
- } = useSandboxAccess(sessionId, state.sessionState?.sandboxStatus === "ready");
+ } = useSandboxAccess(
+ sessionId,
+ state.sessionState?.sandboxStatus === "ready",
+ capabilities.sandboxAccess
+ );
const settleSubscriptionWaiters = useCallback((subscribed: boolean) => {
for (const resolve of subscriptionWaitersRef.current) {
@@ -228,10 +236,14 @@ export function useSessionSocket(
dispatch({ type: "socket_closed" });
}, [settleAllCorrelatedRequests, settleSubscriptionWaiters]);
- const transport = useSessionTransport(sessionId, {
- onMessage: handleMessage,
- onClose: handleClose,
- });
+ const transport = useSessionTransport(
+ sessionId,
+ {
+ onMessage: handleMessage,
+ onClose: handleClose,
+ },
+ capabilities.collaborate
+ );
const { isOpen, send, reconnect, markHealthy } = transport;
useEffect(() => {
diff --git a/packages/web/src/hooks/use-session-transport.test.tsx b/packages/web/src/hooks/use-session-transport.test.tsx
index 6022c8ca7..dcf40ee0f 100644
--- a/packages/web/src/hooks/use-session-transport.test.tsx
+++ b/packages/web/src/hooks/use-session-transport.test.tsx
@@ -110,6 +110,25 @@ describe("useSessionTransport", () => {
expect(result.current.isOpen()).toBe(true);
});
+ it("does not fetch a token or open a socket when transport is disabled", async () => {
+ const { result } = renderHook(() =>
+ useSessionTransport("session-1", { onMessage, onClose }, false)
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(FakeWebSocket.instances).toHaveLength(0);
+ expect(result.current.connected).toBe(false);
+ expect(result.current.connecting).toBe(false);
+
+ act(() => result.current.reconnect());
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(FakeWebSocket.instances).toHaveLength(0);
+ });
+
it("forwards schema-valid messages to onMessage", async () => {
const { socket } = await openSocket();
diff --git a/packages/web/src/hooks/use-session-transport.ts b/packages/web/src/hooks/use-session-transport.ts
index 31b83e81d..3e50eff3e 100644
--- a/packages/web/src/hooks/use-session-transport.ts
+++ b/packages/web/src/hooks/use-session-transport.ts
@@ -23,6 +23,7 @@ const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8787";
const WS_CLOSE_AUTH_REQUIRED = 4001;
const WS_CLOSE_SESSION_EXPIRED = 4002;
const WS_CLOSE_INVALID_MESSAGE = 4004;
+const WS_CLOSE_AUTHORIZATION_REVOKED = 4010;
const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_BASE_DELAY_MS = 1000;
@@ -38,6 +39,7 @@ type CloseDirective =
| { action: "auth_required" }
| { action: "refresh_authorization" }
| { action: "session_expired" }
+ | { action: "authorization_revoked"; delayMs?: number }
| { action: "retry"; delayMs: number }
| { action: "give_up" }
| { action: "none" };
@@ -97,7 +99,8 @@ export interface UseSessionTransportReturn {
*/
export function useSessionTransport(
sessionId: string,
- handlers: SessionTransportHandlers
+ handlers: SessionTransportHandlers,
+ enabled = true
): UseSessionTransportReturn {
const wsRef = useRef(null);
const mountedRef = useRef(true);
@@ -257,6 +260,19 @@ export function useSessionTransport(
wsTokenRef.current = null;
return;
+ case "authorization_revoked":
+ wsTokenRef.current = null;
+ if (!mountedRef.current) return;
+ if (directive.delayMs === undefined) {
+ setConnectionError("Authorization could not be refreshed. Please try reconnecting.");
+ return;
+ }
+ reconnectAttempts.current++;
+ reconnectTimeoutRef.current = setTimeout(() => {
+ if (mountedRef.current) retry();
+ }, directive.delayMs);
+ return;
+
case "retry":
if (!mountedRef.current) return;
reconnectAttempts.current++;
@@ -346,6 +362,7 @@ export function useSessionTransport(
}, []);
const reconnect = useCallback(() => {
+ if (!enabled) return;
// A connect() still awaiting its token must not open a second socket
// alongside the one this call creates.
invalidateInFlightConnect();
@@ -367,7 +384,7 @@ export function useSessionTransport(
setAuthError(null);
setConnectionError(null);
connect();
- }, [connect, invalidateInFlightConnect]);
+ }, [connect, enabled, invalidateInFlightConnect]);
const markHealthy = useCallback(() => {
reconnectAttempts.current = 0;
@@ -376,7 +393,7 @@ export function useSessionTransport(
// Connect on mount
useEffect(() => {
mountedRef.current = true;
- connect();
+ if (enabled) connect();
return () => {
mountedRef.current = false;
@@ -390,10 +407,11 @@ export function useSessionTransport(
discarded.close();
}
};
- }, [connect, invalidateInFlightConnect]);
+ }, [connect, enabled, invalidateInFlightConnect]);
// Ping periodically to keep connection alive.
useEffect(() => {
+ if (!enabled) return;
const pingInterval = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "ping" }));
@@ -401,7 +419,7 @@ export function useSessionTransport(
}, PING_INTERVAL_MS);
return () => clearInterval(pingInterval);
- }, []);
+ }, [enabled]);
return {
connected,
diff --git a/packages/web/src/lib/automation-authorization.test.ts b/packages/web/src/lib/automation-authorization.test.ts
new file mode 100644
index 000000000..6b4ad58db
--- /dev/null
+++ b/packages/web/src/lib/automation-authorization.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import type { EffectiveAuthorization, PermissionId } from "@open-inspect/shared/rbac";
+import { canAccessAutomation } from "./automation-authorization";
+
+const CURRENT_USER_ID = "11111111111111111111111111111111";
+const OTHER_USER_ID = "22222222222222222222222222222222";
+
+function authorization(permissions: PermissionId[]): EffectiveAuthorization {
+ return {
+ userId: CURRENT_USER_ID,
+ suspendedAt: null,
+ role: { id: "role-1", key: null, name: "Test" },
+ permissions,
+ };
+}
+
+describe("canAccessAutomation", () => {
+ it("allows any scope regardless of ownership", () => {
+ expect(
+ canAccessAutomation("automations.manage", authorization(["automations.manage.any"]), {
+ userId: OTHER_USER_ID,
+ })
+ ).toBe(true);
+ });
+
+ it("allows own scope only for the canonical owner", () => {
+ const auth = authorization(["automations.trigger.own"]);
+ expect(canAccessAutomation("automations.trigger", auth, { userId: CURRENT_USER_ID })).toBe(
+ true
+ );
+ expect(canAccessAutomation("automations.trigger", auth, { userId: OTHER_USER_ID })).toBe(false);
+ expect(canAccessAutomation("automations.trigger", auth, { userId: null })).toBe(false);
+ });
+
+ it("denies missing authorization and unrelated capabilities", () => {
+ expect(canAccessAutomation("automations.manage", null, { userId: CURRENT_USER_ID })).toBe(
+ false
+ );
+ expect(
+ canAccessAutomation("automations.manage", authorization(["automations.trigger.any"]), {
+ userId: CURRENT_USER_ID,
+ })
+ ).toBe(false);
+ });
+});
diff --git a/packages/web/src/lib/automation-authorization.ts b/packages/web/src/lib/automation-authorization.ts
new file mode 100644
index 000000000..798f7e2dc
--- /dev/null
+++ b/packages/web/src/lib/automation-authorization.ts
@@ -0,0 +1,17 @@
+import {
+ resolveScopedPermission,
+ type EffectiveAuthorization,
+ type ScopedPermissionStem,
+} from "@open-inspect/shared/rbac";
+import type { Automation } from "@open-inspect/shared/types/automations";
+
+/** Checks an automation capability against its canonical owner identity. */
+export function canAccessAutomation(
+ stem: ScopedPermissionStem,
+ authorization: EffectiveAuthorization | null,
+ automation: Pick
+): boolean {
+ if (!authorization) return false;
+ const scope = resolveScopedPermission(stem, authorization.permissions);
+ return scope === "any" || (scope === "own" && automation.userId === authorization.userId);
+}
diff --git a/public/docs/internal/2026-08-28-rbac-design.md b/public/docs/internal/2026-08-28-rbac-design.md
new file mode 100644
index 000000000..626d894e1
--- /dev/null
+++ b/public/docs/internal/2026-08-28-rbac-design.md
@@ -0,0 +1,815 @@
+# Design: Role-Based Access Control
+
+**Date:** 2026-08-28
+
+**Status:** Proposed
+
+**Research:** [2026-08-28-rbac-research.md](./2026-08-28-rbac-research.md)
+
+## Summary
+
+Open-Inspect will add workspace-level RBAC to its existing single-installation identity model. Each
+canonical human user is assigned exactly one role. A role contains a set of permissions selected
+from a code-owned registry. Four protected built-in roles provide safe defaults. The storage and
+resolution model also supports existing custom roles, but custom-role creation and editing are
+deferred beyond this foundation.
+
+Authorization will be enforced in the control plane after authentication and before business logic.
+The web will receive effective permissions for navigation and control affordances, but client checks
+will remain advisory. Sessions are workspace-wide resources governed by operation permissions, as
+specified in
+[Workspace-Wide Session Authorization](./2026-08-30-workspace-wide-session-authorization-design.md).
+Bot calls will be limited by both a fixed service capability ceiling and, when acting for a human,
+that canonical user's current role.
+
+This design retains one workspace per deployment. It does not add multiple organizations or
+per-repository user grants. The SCM App installation continues to define the repository universe;
+RBAC determines which application actions a user may perform within that universe.
+
+## Goals
+
+- Assign different capability sets to individual canonical users.
+- Provide protected Owner, Administrator, Member, and Viewer roles.
+- Resolve and assign persisted custom roles from a fixed permission registry.
+- Enforce permissions consistently across HTTP routes, session WebSockets, bots, and settings.
+- Distinguish authentication, admission, attribution, resource relationships, and authorization.
+- Preserve existing installation access during migration without leaving the workspace ownerless.
+- Make role assignment and privileged operations durably auditable.
+- Apply role changes promptly to new requests and bounded-lifetime live connections.
+- Keep the authorization API explicit, typed, testable, and deny-by-default.
+
+## Non-Goals
+
+- Multiple workspaces or organizations in one deployment.
+- User/group grants for individual repositories or environments.
+- Synchronizing roles from GitHub, Google, Slack, Linear, or an identity provider.
+- Treating source-control permissions as Open-Inspect roles.
+- A general policy language, conditional expressions, deny rules, or arbitrary customer-defined
+ permission identifiers.
+- Billing plans, quotas, approval workflows, or separation-of-duty constraints.
+- Modeling Cloudflare, Modal, Terraform, or GitHub deployment operators as application users.
+- Changing sandbox-to-control-plane or control-plane-to-Modal machine authentication.
+- Making secret values readable after storage.
+
+## Terminology
+
+| Term | Meaning |
+| --------------------- | --------------------------------------------------------------------------------- |
+| Workspace | The singleton administrative boundary represented by one Open-Inspect deployment. |
+| Principal | An authenticated human user, first-party service, or session-bound sandbox. |
+| Actor | A provider identity asserted by a bot service on behalf of a human. |
+| Role | A named collection of registered permissions. |
+| Built-in role | A protected role shipped by the application with code-defined permissions. |
+| Custom role | A workspace-defined role composed from registered permissions. |
+| Permission | A stable `resource.action` identifier checked by backend policy. |
+| Relationship | Context such as automation ownership used alongside a scoped permission. |
+| Capability ceiling | The maximum permission set a first-party service can exercise. |
+| Effective permissions | The permissions produced by the current role, bounded by principal policy. |
+
+## Decisions
+
+| Area | Decision |
+| ---------------- | ---------------------------------------------------------------------------------------- |
+| Tenancy | One implicit workspace per deployment. |
+| User assignment | Exactly one role per canonical user. |
+| Role model | Four protected built-ins plus custom roles. |
+| Permission model | Fixed allow-only registry owned in shared code. Missing permission denies. |
+| Enforcement | Control plane is authoritative; web checks are presentation only. |
+| Resource scoping | Workspace-wide sessions plus contextual own/any automation actions. |
+| Repository scope | SCM installation defines visibility; role permissions govern app operations. |
+| Services | Static service ceilings; actor-backed calls use ceiling/actor intersection. |
+| Sandboxes | Existing session-bound capability model remains separate from human RBAC. |
+| Role changes | Immediate for HTTP; short authorization leases bound live browser connections. |
+| Audit | Durable audit events for RBAC changes and sensitive mutations; structured denial logs. |
+| Owner bootstrap | Every deployment requires an explicit operator bootstrap after the Owner signs in. |
+| Migration | Existing canonical users become Administrator; the operator explicitly bootstraps Owner. |
+
+## Authorization Model
+
+### Built-in roles
+
+The built-in roles are stable system records. Their names and permission sets are defined in code
+and cannot be deleted or edited through the application.
+
+| Role | Intended capability |
+| ------------- | --------------------------------------------------------------------------------------------- |
+| Owner | Full application access, role management, member management, and ownership transfer. |
+| Administrator | Full operational access except ownership transfer and protected Owner changes. |
+| Member | Create and operate sessions and automations; use shared targets; no sensitive administration. |
+| Viewer | Read shared operational state and session output; no launches or shared-resource mutations. |
+
+Owner is not represented by a wildcard. It receives every registered permission explicitly when
+permissions are resolved. This makes newly introduced permissions visible in review and prevents
+custom permission strings from becoming executable.
+
+### Custom roles
+
+The data model and permission resolver retain support for persisted custom roles so assignments and
+effective authorization do not depend on built-in role keys. This foundation exposes custom roles
+through read and assignment APIs only; creating, editing, and deleting them is deferred until there
+is a concrete administration workflow. Persisted custom permissions must be registry members, cannot
+include `workspace.transfer_ownership`, and remain allow-only without inheritance or deny entries.
+
+One role per user avoids ambiguous permission union, ordering, and deny precedence. A later group or
+multi-role system can expand assignment cardinality without changing permission identifiers or route
+checks.
+
+### Permission registry
+
+Permissions are exported from `@open-inspect/shared` as stable identifiers and protected built-in
+role sets. Built-in policy changes deploy with code and do not require a data migration. Persisted
+`role_permissions` rows are the runtime authority only for workspace-defined custom roles. Unknown
+identifiers fail role validation and are ignored during effective-permission resolution. Permission
+IDs are never reused for different semantics.
+
+### Permission catalog
+
+#### Workspace and identity
+
+| Permission | Actions |
+| ------------------------------ | --------------------------------------------------------------------- |
+| `workspace.members.read` | List users, identities, roles, and assignment state. |
+| `workspace.members.manage` | Assign roles other than Owner; suspend or restore application access. |
+| `workspace.roles.read` | List role definitions and permission catalog. |
+| `workspace.transfer_ownership` | Assign/remove Owner while preserving at least one Owner. |
+
+#### Repositories and environments
+
+| Permission | Actions |
+| ------------------------------ | ----------------------------------------------------------------- |
+| `repositories.read` | List installed repositories, branches, and metadata. |
+| `repositories.use` | Select repositories as session or automation targets. |
+| `repositories.settings.manage` | Change repository SCM, sandbox, and integration overrides. |
+| `repositories.secrets.manage` | Create, update, or delete repository secrets. |
+| `repositories.images.manage` | Toggle or trigger repository image builds. |
+| `environments.read` | List and inspect environments and memberships. |
+| `environments.use` | Select environments as session or automation targets. |
+| `environments.manage` | Create, update, or delete environments and repository membership. |
+| `environments.settings.manage` | Change environment integration and sandbox overrides. |
+| `environments.secrets.manage` | Create, update, delete, or import environment secrets. |
+| `environments.images.manage` | Toggle or trigger environment image builds. |
+
+#### Sessions
+
+| Permission | Actions |
+| ------------------------- | --------------------------------------------------------------------- |
+| `sessions.create` | Create a session using an allowed target. |
+| `sessions.read` | Read every workspace session. |
+| `sessions.collaborate` | Prompt, attach files, and connect to every workspace session. |
+| `sessions.lifecycle` | Rename, archive, unarchive, stop, cancel, and refresh any session. |
+| `sessions.delete` | Delete any workspace session. |
+| `sessions.sandbox_access` | Obtain terminal, VNC, code-server, or sandbox access for any session. |
+
+Session creator and participant data are attribution and runtime identity, not authorization.
+Read-state changes require `sessions.read` and always mutate only the caller's own read state.
+
+#### Automations and analytics
+
+| Permission | Actions |
+| ------------------------- | ---------------------------------------------------------------------------- |
+| `automations.read` | List automation definitions and run history. |
+| `automations.create` | Create an automation with allowed targets and provider mode. |
+| `automations.manage.own` | Edit, pause, resume, rotate keys, or delete automations created by the user. |
+| `automations.manage.any` | Manage any automation. |
+| `automations.trigger.own` | Manually execute an automation created by the user. |
+| `automations.trigger.any` | Manually execute any automation. |
+| `analytics.read` | View installation-wide session, repository, user, and PR analytics. |
+
+#### Models, integrations, and execution configuration
+
+| Permission | Actions |
+| --------------------------- | -------------------------------------------------------------------------- |
+| `models.preferences.manage` | Change enabled model preferences. |
+| `provider_accounts.read` | View provider account metadata, status, and defaults. |
+| `provider_accounts.manage` | Connect, reconnect, rename, verify, enable, disable, and default accounts. |
+| `integrations.read` | View integration, SCM, sandbox, and commit-signing metadata. |
+| `integrations.manage` | Change global integration and sandbox settings. |
+| `scm_settings.manage` | Change deployment-wide SCM settings. |
+| `commit_signing.manage` | Configure or remove deployment-wide signing material. |
+| `global_secrets.manage` | Create, update, or delete global secrets. |
+| `image_builds.read` | View repository/environment image build status and history. |
+
+#### Extensibility
+
+| Permission | Actions |
+| --------------------------- | ------------------------------------------------------------------------- |
+| `skills.read` | List shared managed skills. |
+| `skills.manage` | Import, edit, assign, reimport, enable, disable, or delete shared skills. |
+| `skill_profiles.manage_own` | Manage only the caller's skill profiles. |
+| `mcp_servers.read` | List MCP server definitions. |
+| `mcp_servers.manage` | Create, update, or delete MCP server definitions. |
+
+Personal keyboard shortcuts and browser-local appearance require only an authenticated, active user.
+They do not need role permissions because they cannot affect another user or shared execution.
+
+### Default role matrix
+
+The table groups permissions for readability; the registry stores individual identifiers.
+
+| Capability group | Owner | Administrator | Member | Viewer |
+| -------------------------------------------------------- | :---: | :-----------: | :------: | :----: |
+| Workspace, member, role, and audit read | Yes | Yes | No | No |
+| Manage members | Yes | Yes | No | No |
+| Transfer Owner role | Yes | No | No | No |
+| Read repositories and environments | Yes | Yes | Yes | Yes |
+| Use repositories and environments | Yes | Yes | Yes | No |
+| Read image-build status and history | Yes | Yes | Yes | Yes |
+| Manage environments/settings/images | Yes | Yes | No | No |
+| Manage global/repository/environment secrets | Yes | Yes | No | No |
+| Create sessions | Yes | Yes | Yes | No |
+| Read any session | Yes | Yes | Yes | Yes |
+| Collaborate in any session | Yes | Yes | Yes | No |
+| Perform session lifecycle operations | Yes | Yes | Yes | No |
+| Delete sessions | Yes | Yes | Yes | No |
+| Obtain sandbox access | Yes | Yes | Yes | No |
+| Read automations | Yes | Yes | Yes | Yes |
+| Create/manage/trigger automations | Yes | Yes | Own only | No |
+| Read analytics | Yes | Yes | Yes | Yes |
+| Manage models/provider accounts/integrations/SCM/signing | Yes | Yes | No | No |
+| Read shared skills and MCP servers | Yes | Yes | Yes | Yes |
+| Manage shared skills and MCP servers | Yes | Yes | No | No |
+| Manage own skill profiles | Yes | Yes | Yes | No |
+| Manage personal preferences | Yes | Yes | Yes | Yes |
+
+Viewer receives `sessions.read` but no collaborate or lifecycle permission. Member receives every
+non-administrative session operation across the workspace. Administrator preserves the existing
+broad operational behavior.
+
+## Data Model
+
+### Tables
+
+```sql
+CREATE TABLE roles (
+ id TEXT PRIMARY KEY,
+ key TEXT UNIQUE,
+ name TEXT NOT NULL,
+ normalized_name TEXT NOT NULL UNIQUE,
+ description TEXT,
+ is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0, 1)),
+ CHECK ((is_system = 1 AND key IN ('owner', 'administrator', 'member', 'viewer'))
+ OR (is_system = 0 AND key IS NULL))
+);
+
+CREATE TABLE role_permissions (
+ role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
+ permission_id TEXT NOT NULL,
+ PRIMARY KEY (role_id, permission_id)
+);
+
+CREATE TABLE user_role_assignments (
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE RESTRICT,
+ role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE RESTRICT
+);
+
+CREATE TABLE authorization_audit_events (
+ id TEXT PRIMARY KEY,
+ occurred_at INTEGER NOT NULL,
+ request_id TEXT NOT NULL,
+ principal_kind TEXT NOT NULL,
+ actor_user_id_snapshot TEXT,
+ actor_service_snapshot TEXT,
+ action TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ resource_id TEXT,
+ target_user_id_snapshot TEXT,
+ reason_code TEXT NOT NULL
+);
+
+CREATE INDEX idx_role_assignments_role ON user_role_assignments(role_id, user_id);
+```
+
+Built-in roles have stable `key` values: `owner`, `administrator`, `member`, and `viewer`; their
+permission sets come from the shared code registry and have no `role_permissions` rows. Custom roles
+have `key = NULL`, and their permission rows are the runtime authority. IDs are opaque; role names
+are display values. This foundation does not expose custom-role mutations.
+
+`users` gains:
+
+```sql
+ALTER TABLE users ADD COLUMN suspended_at INTEGER;
+```
+
+Suspension records the time access was disabled without deleting identities or historical
+attribution. A null value means the user is active.
+
+Every canonical identity is an active workspace member unless suspended. The RBAC migration seeds
+the built-in roles, assigns Administrator to every existing canonical user, and then creates the
+default-role trigger. Every identity created afterward receives Member, including identities first
+observed through a bot. Identity creation and default role assignment are one database-triggered
+workflow. Authorization denies a missing assignment; ordinary sign-in and identity resolution never
+repair authorization corruption implicitly.
+
+Initial ownership is assigned only by the root operator CLI after the intended Owner has signed in
+once. The operator supplies the canonical user ID, not an email or browser credential. One temporary
+SQL file and one Wrangler D1 execution validate the RBAC schema, unsuspended user, exact assignment,
+and absence of another unsuspended Owner before atomically writing a redacted `operator-cli` audit
+event and assigning `role_builtin_owner`. The final SQL guard verifies the exact generated audit ID
+and aborts the operation if the resulting state is inconsistent. Re-running for the current
+unsuspended Owner is a no-op and writes nothing. Ownership changes after initialization use the
+authenticated member API.
+
+### Storage ownership
+
+- D1 is the source of truth for roles, assignments, status, custom-role grants, and audit events.
+- Shared code defines the permission catalog and built-in role grants; persisted permission rows are
+ the runtime grant authority for custom roles.
+- Session creator attribution remains in D1 and is not an authorization relationship.
+- Participant attribution remains in the Session Durable Object for message identity, presence, SCM
+ metadata, and WebSocket tokens.
+- No role or permission set is copied into sessions, automations, or provider accounts.
+
+## Policy Engine
+
+### Interface
+
+Authorization is invoked through one control-plane service rather than direct role-table queries in
+handlers:
+
+```ts
+type AuthorizationRequest = {
+ principal: Principal;
+ permission: PermissionId;
+ resource?: AuthorizationResource;
+};
+
+type AuthorizationDecision = {
+ allowed: boolean;
+ reason: AuthorizationReason;
+ actorUserId: string | null;
+};
+```
+
+The engine exposes `requirePermission()` for ordinary checks and an automation resource helper for
+owner-scoped automation policy. Denial throws a typed `403` error with a stable reason code.
+Authentication failures remain `401`; missing resources remain `404` after permission admission.
+
+### Human decision flow
+
+1. Require an active canonical user.
+2. Load the user's role assignment and registered permission set.
+3. Deny if no assignment exists.
+4. Check the requested permission.
+5. For owner-scoped automation permissions, load the automation owner.
+6. Return an allow/deny decision with a stable reason.
+
+### Service decision flow
+
+Each service has a code-defined ceiling:
+
+- `web` may proxy browser-auth and discovery operations only; browser application routes authorize
+ the human user principal produced by composed authentication.
+- `github-bot` may read repository/environment launch metadata, create sessions, read, prompt, or
+ stop workspace sessions, and post GitHub automation events.
+- `slack-bot` may read launch catalogs/preferences, create sessions, operate sessions mapped to its
+ Slack thread, upload/download session media, and post Slack events.
+- `linear-bot` may read launch catalogs/preferences, create sessions, and operate sessions mapped to
+ its Linear issue/agent session.
+
+For an actor-backed service request:
+
+```text
+effective = service ceiling ∩ actor role permissions
+```
+
+The actor must resolve to an active canonical user with a role assignment. Service-authenticated
+identity enrollment resolves or creates the canonical identity before business authorization and
+idempotently assigns the migration default: Administrator for identities captured by the migration,
+Member afterward. A first bot interaction can therefore proceed with Member capabilities but can
+never claim Owner. Provider webhook verification and GitHub collaborator checks remain additional
+admission conditions, never substitutes for application authorization.
+
+Actorless callbacks, normalized webhook events, and automation triggers use narrow service-only
+permissions declared for their exact endpoints. They cannot use broad `user-or-service` management
+routes.
+
+### Sandbox decision flow
+
+Sandbox authentication remains a scoped capability. A valid sandbox principal can call only route
+operations explicitly designated for a sandbox bound to the same session. It does not inherit the
+session creator's role and does not gain workspace permissions. Human role changes do not terminate
+an executing sandbox, but they can remove human access to its session and controls.
+
+### Session authorization and identity
+
+Session operations are workspace-scoped. A user with a session operation permission may apply it to
+every session, regardless of creator or participant identity. Deletion is also workspace-scoped.
+
+`sessions.user_id` retains immutable creator attribution for display, filtering, auditing, and
+credential lineage. Session Durable Object participants retain message identity, presence, SCM
+metadata, and WebSocket token ownership. Neither is an authorization grant.
+
+Creating a WebSocket token or sending a prompt requires `sessions.collaborate`. WebSocket
+subscription rechecks the represented canonical user's active role and collaboration permission.
+Private, invitation-only, participant-restricted, and creator-only session behavior is deferred.
+
+### Automation execution authority
+
+Automation definitions retain a canonical owner. Every invocation reauthorizes current state rather
+than replaying stored creator authority:
+
+| Trigger | Initiating actor | Execution principal | Required current authority |
+| ------------ | ----------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------- |
+| Manual | Requesting user | Requesting user | own/any trigger, target use, session create |
+| Schedule | Scheduler service | Automation owner | active owner, manage-own, target use, session create |
+| Webhook key | Narrow webhook capability | Automation owner | active owner, manage-own, target use, session create |
+| Sentry | Verified Sentry webhook | Automation owner | active owner, manage-own, target use, session create |
+| GitHub event | Verified GitHub service actor | Canonical GitHub actor | service ceiling; active actor with session create and target use; active owner with manage-own |
+| Slack event | Verified Slack service actor | Canonical Slack actor | service ceiling; active actor with session create and target use; active owner with manage-own |
+| Linear event | Verified Linear service actor | Canonical Linear actor | service ceiling; active actor with session create and target use; active owner with manage-own |
+
+The resulting session is owned by and attributed to the named canonical execution principal. The
+initiator, service, and automation owner are recorded separately in invocation/audit metadata. Skill
+profiles and user-linked credentials come from the execution principal; installation-wide secrets
+and provider accounts remain selected by the automation's current allowed configuration. A manual
+trigger never runs as another user's stored identity. Loss of any conjunctive authority marks the
+invocation `skipped_authorization` without launching a session. Repeated scheduled or webhook
+authorization failures pause the automation after the existing failure threshold and notify
+administrators. Provider-account and secret resolution is repeated under the current execution
+policy.
+
+New automations require an active canonical owner. Historical automations with missing or unresolved
+owners are disabled during migration and require explicit reassignment by an Administrator or Owner
+before execution.
+
+## Route Enforcement
+
+### Route metadata
+
+Authentication policy remains responsible for proving principal kind. Every route declaration also
+contains required authorization metadata. Static permission routes declare the permission beside the
+method and pattern:
+
+```ts
+authorization: requirePermission("environments.manage");
+```
+
+Session routes identify the operation applied to the already-matched path parameter. Conjunctive
+policies list every requirement explicitly:
+
+```ts
+authorization: requireAll(
+ permissionRequirement("sessions.create"),
+ permissionRequirement("sessions.collaborate")
+);
+```
+
+The router executes declared permission, session-operation, and automation checks before handlers.
+Request admission uses current authorization; a concurrent role change does not retroactively revoke
+an admitted HTTP request. Personal active-user routes, active global routes, public routes, and
+service-only callbacks each use an explicit policy kind; narrow internal callbacks name their exact
+service. `router.policy.test.ts` rejects missing metadata, duplicate method/pattern pairs,
+incompatible authentication/authorization combinations, and session requirements that reference
+absent match groups.
+
+### Exemptions
+
+Only these ingress/authentication classes bypass browser authentication:
+
+- public health;
+- browser-auth protocol endpoints;
+- externally authenticated webhook ingress;
+- image-build capability callbacks;
+- session-bound sandbox routes;
+- narrow internal service callbacks.
+
+Each exemption names its alternate ingress mechanism in route metadata. Webhook authenticity permits
+normalization/queueing only; every resulting automation or resource operation still applies the
+execution-authority policy before side effects. `user-or-service` alone is never sufficient
+authorization after this change.
+
+A generated route-to-policy inventory covers every session, child-session, attachment, media, diff,
+pull-request, credential, automation, secret, settings, and callback endpoint. Sandbox child
+operations remain parent-session-bound; human child operations use workspace session permissions.
+
+### Listing and filtering
+
+Authorization applies before list queries, with contextual automation ownership applied in SQL where
+needed.
+
+- Every user with `sessions.read` receives the workspace session list.
+- Creator and Mine filters use `sessions.user_id` as attribution, not access control.
+- Automation lists use `manage.any/read` or creator ownership as appropriate.
+- Resources requiring a missing read permission are omitted from catalogs and navigation.
+- Repository/environment catalogs require read permission; use permission is separately checked when
+ launching or configuring an execution target.
+
+## API Contracts
+
+### Current user authorization
+
+`GET /me/authorization` returns:
+
+```json
+{
+ "userId": "canonical-id",
+ "suspendedAt": null,
+ "role": { "id": "role-id", "key": "member", "name": "Member" },
+ "permissions": ["repositories.read", "sessions.create"]
+}
+```
+
+This endpoint is available only to the current browser user. Responses are private and no-store.
+
+### Role administration
+
+| Method | Path | Permission | Purpose |
+| ------ | ------------ | ---------------------- | ------------------------------------ |
+| `GET` | `/roles` | `workspace.roles.read` | List roles, counts, and permissions. |
+| `GET` | `/roles/:id` | `workspace.roles.read` | Read one role and permissions. |
+
+### Member administration
+
+| Method | Path | Permission | Purpose |
+| ------ | ------------------------- | -------------------------------------- | ------------------------------------- |
+| `GET` | `/members` | `workspace.members.read` | List canonical users and assignments. |
+| `PUT` | `/members/:userId/role` | `workspace.members.manage` or transfer | Replace one assignment. |
+| `PUT` | `/members/:userId/status` | `workspace.members.manage` | Suspend or restore access. |
+
+Owner assignment or removal requires `workspace.transfer_ownership`, including when the caller also
+has member-management permission. Suspending, deleting, or merging an Owner also requires transfer
+permission. Every role/status/delete/merge mutation uses guarded SQL that succeeds only if another
+unsuspended Owner remains in the same D1 batch. User deletion is blocked by assignment
+`ON DELETE RESTRICT`; the assignment can be removed only through this guarded membership service.
+User merge requires an explicit surviving assignment, repoints canonical session creator
+attribution, and preserves both immutable audit snapshots.
+
+Assignment and status updates apply the request-scoped authorization decision and preserve Owner
+invariants in the same D1 batch as the mutation. Authorization changes do not retroactively revoke
+an already admitted request.
+
+### Error contract
+
+Forbidden API responses use:
+
+```json
+{
+ "error": "Forbidden",
+ "code": "permission_required",
+ "permission": "environments.manage"
+}
+```
+
+Other denials use codes such as `active_user_required` and `service_capability_required`. Responses
+do not disclose another user's role.
+
+## Web Experience
+
+### Authorization state
+
+The app shell loads current authorization with the browser session. It distinguishes:
+
+- unauthenticated;
+- authenticated but suspended/unassigned;
+- authenticated and authorized;
+- authorization service unavailable.
+
+Permission checks consume the stable `hasPermission` predicate from the current-user authorization
+hook. They hide navigation that has no readable content and disable contextual controls when
+explaining the missing capability is useful. Server-rendered session pages authorize before fetching
+snapshots.
+
+### Members and roles
+
+A Workspace settings section contains:
+
+- Members: identity, provider links, status, role, last activity, and assignment actions.
+- Roles: built-in/custom roles, assignment count, and categorized permission details.
+- Audit log: actor, action, target, outcome, reason, and timestamp.
+
+The UI prevents removing the last unsuspended Owner and assigning Owner without transfer permission.
+The API repeats every invariant.
+
+### Existing navigation
+
+- Settings tabs appear only when at least one permission makes them useful.
+- New session requires `sessions.create` plus target `use` permission.
+- All/Mine becomes All/My sessions; both are filters over the workspace-wide session list.
+- Session controls reflect read, collaborate, lifecycle, delete, and sandbox-access permissions
+ independently.
+- Analytics requires `analytics.read`.
+- Automation create/manage actions are independent from automation read access.
+
+The browser never treats hidden controls or downloaded permissions as security enforcement.
+
+## Audit and Observability
+
+Durable audit events are required for:
+
+- user role assignment;
+- access suspension/restoration;
+- Owner assignment/removal;
+- secret, provider-account, commit-signing, integration, SCM, MCP, and shared-skill mutations;
+- allowed and denied member-management operations.
+
+Pure D1 mutations write the audit event in the same D1 batch.
+
+High-volume ordinary reads and successful session messages remain in structured request logs rather
+than D1 audit storage. Every authorization denial logs principal kind, actor user ID when known,
+permission, policy, resource type, opaque resource ID, reason code, request ID, and service name.
+Secret values, OAuth credentials, prompt content, and signed tokens never enter audit metadata.
+
+Metrics include denial count by permission/reason/principal, unassigned active users, assignment
+count by role, and authorization latency.
+
+## Role Changes and Revocation
+
+- HTTP requests load current assignment/status and apply changes immediately.
+- Role permission edits take effect on the next authorization lookup.
+- Browser WebSocket credentials are bound to the canonical user. Subscribe verifies current D1
+ authorization and rejects missing or suspended users, missing role assignments, and unavailable
+ authorization storage.
+- A successful subscribe asks the WebSocket manager to grant a five-minute wall-clock authorization
+ lease. The manager persists its expiry in `ws_client_mapping` and owns earliest-expiry scheduling
+ in the unified alarm. On expiry the browser clears its credential and reconnects through the
+ authorized HTTP token route.
+- Alarm and hibernation restoration close every expired connection even when it is idle. Every
+ inbound event and outbound broadcast also rejects expired leases as defense in depth. A role
+ change therefore revokes live browser access within the five-minute wall-clock lease bound.
+- Bot calls authorize on every signed HTTP request. Stale Slack/Linear issue mappings do not bypass
+ current policy.
+- Suspending a user invalidates Better Auth sessions.
+- Existing sandboxes continue running because their credentials represent the session runtime, not
+ the user. Users who lose lifecycle permission cannot reconnect or control them.
+
+## Migration and Compatibility
+
+The migration is additive and preserves current capability for every canonical user:
+
+1. Create role, permission, assignment, and audit tables.
+2. Insert protected built-in role records; their permission sets remain code-owned.
+3. Assign Administrator to every canonical user present in `users`, including identities originally
+ created through Slack, GitHub, or Linear.
+4. Create the unconditional default-role trigger. Identity provisioning after this point assigns
+ Member.
+
+No route switches to enforcement until every existing canonical user has an Administrator assignment
+and built-in role reconciliation succeeds. Administrators may continue using the application before
+Owner bootstrap. After deployment, the intended Owner signs in once to create a canonical user and
+assignment. An operator then dry-runs and executes the root CLI against that canonical ID. Sign-in
+and bot identity creation never assign Owner.
+
+Deployment documentation will state that Administrator preserves the previous installation-wide
+operational behavior, while Member becomes the default for newly admitted users.
+
+### Operator bootstrap
+
+Terraform exports the D1 database name but does not configure an Owner identity. The supported
+sequence is deploy, have the intended Owner sign in once, obtain the canonical ID from the browser
+session, run `npm run rbac:bootstrap-owner -- --database --user `, review the dry-run
+preflight, rerun with `--execute`, and verify `/health` reports `rbac.ownerAssignment=present`.
+
+When an unsuspended Owner assignment exists, `/health` reports `rbac.ownerAssignment=present`; when
+none exists, it reports `missing`. Administrators and Members can use their existing capabilities,
+but no one can exercise Owner-only actions.
+
+## Failure Handling
+
+- D1 authorization lookup failure denies the request and returns `503 authorization_unavailable`; it
+ never falls back to broad authenticated access.
+- Missing or unknown role permissions deny and emit a reconciliation error.
+- Missing user assignment denies shared application routes but permits sign-out and own identity
+ discovery so an administrator can repair access.
+- Audit-write failure aborts transactional D1 administration.
+- Web authorization metadata failure renders an unavailable state rather than the unrestricted app.
+
+## Security Invariants
+
+1. Authentication never implies authorization.
+2. Admission allowlists never imply a role beyond bootstrap/default assignment.
+3. Unknown permissions, missing assignments, suspended users, and policy errors deny access.
+4. Client-side permission checks are never authoritative.
+5. Creator and participant attribution are not authorization checks.
+6. A service cannot exceed its code-defined ceiling.
+7. An actor-backed service cannot exceed the linked user's current permissions.
+8. An actorless service can execute only exact service-only operations.
+9. Sandbox credentials remain bound to one session and confer no workspace role.
+10. Before bootstrap, no user can exercise Owner-only actions; after bootstrap, at least one
+ unsuspended Owner always exists.
+11. Only an Owner can add or remove Owner assignments.
+12. Role changes and privileged mutations produce durable, redacted audit events.
+13. Session lists require workspace read permission before returning metadata.
+14. Secret-management permission never makes stored secret values readable.
+15. External provider authorization is additional evidence, not a replacement for application RBAC.
+
+## Testing Strategy
+
+### Shared
+
+- Permission registry uniqueness and stable serialization.
+- Built-in role snapshots and persisted custom-role resolution.
+- API schema rejection of malformed role responses and assignments.
+
+### Control-plane unit
+
+- Human permission allow/deny matrix for every built-in role.
+- Custom role resolution, suspension, missing assignment, and unknown permission behavior.
+- Workspace-wide session operation permissions for every built-in role.
+- Service ceiling and actor intersection for every bot.
+- Actorless exact-endpoint service permissions.
+- Last-Owner, built-in-role, assignment, and transaction invariants.
+- Concurrent Owner demotion/suspension/delete and user-merge conflicts.
+- Stable `401`, `403`, `404`, and `503` behavior.
+- Route policy completeness requiring authorization metadata or named exemption.
+
+### Control-plane integration
+
+- Multi-user tests proving permitted Members can read, collaborate, manage lifecycle, access the
+ sandbox, and delete across workspace sessions.
+- Viewer can read but cannot prompt, launch, stop, delete, or access sandbox credentials.
+- Administrator can operate installation-wide resources but cannot transfer Owner.
+- Owner can assign roles without removing the last unsuspended Owner.
+- Secret/settings/provider-account/skill/MCP/image routes enforce individual permissions.
+- Session lists remain workspace-wide while creator and Mine filters preserve attribution semantics.
+- Role changes are enforced when idle, active, hibernated, and multi-tab WebSocket authorization
+ leases expire.
+- Suspended browser sessions and bot actors are denied.
+- D1 failure fails closed and audit failure aborts protected mutations.
+- Automation schedule, webhook, event, and manual triggers reauthorize the correct execution
+ principal after owner suspension, demotion, role edit, and target-access loss.
+- Sentry, GitHub, Slack, and Linear trigger tests assert session owner, initiator audit fields,
+ owner guard, service ceiling, actor permission intersection, and credential/profile source.
+
+### Web
+
+- Navigation and controls for Owner, Administrator, Member, Viewer, custom, suspended, and
+ unavailable states.
+- Direct URL access remains denied when navigation is hidden.
+- Session server rendering does not fetch unauthorized snapshots.
+- Workspace member controls enforce API invariants.
+- Generic forbidden responses do not trigger sign-in flows.
+
+### Bots
+
+- Each service can call only its ceiling routes.
+- Linked actor role is required for actor-backed launches and prompts.
+- Unlinked, suspended, and underprivileged actors fail closed with user-safe provider responses.
+- Existing GitHub collaborator, Slack webhook, and Linear organization checks remain enforced.
+- External session mappings cannot bypass actor role or service ceiling checks.
+
+### Migration
+
+- Empty installation assigns Member to new identities and requires an explicit canonical-ID operator
+ bootstrap for the initial Owner.
+- Existing installation assigns every pre-migration canonical user Administrator, including bot-only
+ identities, then requires the same explicit operator bootstrap.
+- Every canonical user receives exactly one assignment.
+- Built-in role reconciliation is idempotent and rejects incompatible registry drift.
+- Exact migration SQL executes under workerd/D1, including indexes and constraints.
+- Better Auth or bot identity creation followed by assignment failure cannot enter business routes
+ and retries Member assignment idempotently.
+- Owner bootstrap requires an existing unsuspended canonical user with exactly one assignment and
+ refuses another unsuspended Owner.
+- CLI bootstrap is atomic and idempotent, writes exactly one redacted operator audit event on a
+ ready transition, and writes nothing when the target is already the current Owner.
+
+## Alternatives Considered
+
+### Role column on `users`
+
+Rejected because it cannot represent custom role metadata and permission composition without
+hard-coding authorization throughout handlers.
+
+### Multiple roles per user
+
+Rejected for the initial system because role union and future deny semantics add complexity without
+a current user requirement. One assignment directly matches user-level role configuration.
+
+### Per-repository and per-environment grants
+
+Deferred because current deployment identity and repository discovery are installation-wide. Adding
+resource grants would require group semantics, environment membership rules, bot grant mapping, and
+SCM synchronization decisions not resolved by current product behavior.
+
+### Encode permissions in browser sessions
+
+Rejected because role changes would remain stale for the Better Auth session lifetime and backend
+handlers would still need authoritative policy state.
+
+### Use Session Durable Object participant roles as application RBAC
+
+Rejected because those roles exist only inside one session, are auto-created by current workflows,
+and cannot govern installation settings or repository/environment actions.
+
+### External policy engine
+
+Rejected because the initial policy consists of a small fixed permission registry plus contextual
+automation ownership. D1 and typed control-plane policy keep the trust boundary and operational
+footprint within the existing architecture.
+
+## Open Product Decisions
+
+The design chooses defaults for implementation, but product confirmation is required before
+enforcement:
+
+1. Session operations are workspace-wide when granted by the user's role.
+2. New canonical users default to Member after the RBAC migration boundary.
+3. Administrator receives all operational permissions except ownership transfer.
+4. Persisted custom roles cannot receive ownership transfer.
+5. Repository and environment access remains installation-wide rather than user-granted.
+6. Existing users are promoted to Administrator to preserve current access.
+7. Executing sandboxes continue after their creator is suspended or demoted.
+8. Authorization audit events are retained under the deployment's existing D1 retention policy.
+9. Scheduled/webhook automations stop launching when their owner loses current execution authority.
+10. Session creator and participant identities are attribution, not authorization.
+11. Five minutes is a strict wall-clock browser WebSocket revocation bound, including idle sockets.
diff --git a/public/docs/internal/2026-08-28-rbac-research.md b/public/docs/internal/2026-08-28-rbac-research.md
new file mode 100644
index 000000000..8384e78bb
--- /dev/null
+++ b/public/docs/internal/2026-08-28-rbac-research.md
@@ -0,0 +1,386 @@
+# Research: Role-Based Access Control
+
+**Date:** 2026-08-28
+
+**Status:** Superseded research snapshot
+
+**Scope:** Current identity, authentication, authorization, resources, actions, storage, user
+workflows, service integrations, and operational trust boundaries relevant to application RBAC.
+
+The implemented model is documented in [Role-Based Access Control](./2026-08-28-rbac-design.md).
+
+This document is intentionally research-only. It does not include recommendations, implementation
+plans, proposed code/API/schema changes, task breakdowns, estimates, or rollout steps.
+
+## Summary
+
+Open-Inspect authenticates human users, first-party services, and session-bound sandboxes, but it
+does not have an application role, workspace membership, permission, grant, or administrator model.
+The deployment is explicitly single-tenant: admission policy determines who may sign in, and an
+admitted human generally shares installation-wide access to repositories, sessions, environments,
+secrets, settings, provider accounts, automations, skills, MCP servers, image controls, and
+analytics.
+
+Human identity is canonicalized across GitHub, Google, Slack, and Linear. First-party bots sign
+requests as distinct services and may assert actors in their own provider namespace. Sandboxes use
+credentials bound to one session. These principal distinctions constrain authentication channels,
+but most route policies do not distinguish capabilities among admitted humans or among signed bot
+services.
+
+Sessions contain `owner` and `member` participants, but those roles are not a general authorization
+boundary. Session creator fields primarily support attribution and filtering. Existing visibility
+logic deliberately returns any session in the installation, and authenticated users or services can
+join, prompt, inspect, stop, or mutate many sessions without an owner check.
+
+The application has three broad resource scopes today: per-user preferences, session-scoped runtime
+state, and installation-wide operational resources. Repository and environment resources do not have
+application membership or grant records. External source-control permissions are consulted in some
+GitHub bot trigger paths, but ordinary web and service access uses the deployment's SCM App or token
+authority.
+
+## Research Questions
+
+1. Which identities and authentication channels exist today?
+2. Which application resources and actions would intersect with authorization decisions?
+3. Which resources are personal, session-scoped, repository/environment-scoped, or
+ installation-wide?
+4. Where are authorization decisions currently made, and what do they enforce?
+5. How do Slack, GitHub, Linear, sandboxes, and deployment operators cross trust boundaries?
+6. Which current fields represent attribution rather than ownership or access?
+7. Which gaps and unresolved product semantics affect an RBAC design?
+
+## Current Behavior
+
+### Human identity and admission
+
+- Canonical users are stored in D1 `users`; provider identities are stored in `user_identities` and
+ linked by canonical user ID.
+- Browser sign-in supports GitHub and Google through Better Auth. Browser requests reach the control
+ plane through a signed `service:web` channel and a valid browser session cookie.
+- Admission supports GitHub login, email, email domain, and GitHub organization allowlists, plus an
+ explicit unsafe allow-all mode. Admission only controls sign-in eligibility.
+- The browser session contract exposes user ID, name, email, and image. It has no role, permission,
+ membership, workspace, or resource-grant data.
+- Canonical user IDs currently scope keyboard shortcuts, managed-skill profiles, session read state,
+ temporary provider-account authorization transactions, and the session-list `Mine` filter.
+
+### Request principals and route policies
+
+The control plane resolves every authenticated request to one of:
+
+| Principal | Identity boundary | Current use |
+| ------------------- | ----------------------------------------- | -------------------------------------------------- |
+| Human user | Canonical user ID | Browser-originated application requests |
+| First-party service | Service name plus optional asserted actor | Web, Slack, GitHub, and Linear Workers |
+| Sandbox | Session ID | Session runtime callbacks and credential brokerage |
+
+Route authentication distinguishes public, handler-authenticated, web-service, human-user,
+user-or-service, sandbox, and sandbox-fallback requests. It does not express application actions,
+resource scopes, user roles, or grants. Human-only routes exclude bots but admit every authenticated
+human. Most `user-or-service` routes admit every signed first-party service, not a named subset.
+
+### Session visibility and participation
+
+- Session creation stores a canonical creator in the D1 session index and creates a Durable Object
+ participant with role `owner`.
+- Other identities are added as `member` participants when they request a WebSocket token or send a
+ prompt.
+- `SessionIndexStore.getVisibleForUser()` deliberately ignores the supplied user ID and returns any
+ existing session. Its source comment names this the single-tenant visibility boundary.
+- Session lists are global unless `createdBy=me` is supplied as an explicit filter.
+- Session title, archive, and unarchive handlers require participation, but do not distinguish
+ `owner` from `member`. Other lifecycle and runtime routes do not consistently require existing
+ participation.
+- An authenticated user or asserted service actor can request a WebSocket token for a session and be
+ added as a member. Prompt submission follows the same auto-membership pattern.
+- Deletion, stop, event, artifact, media, attachment, participant, pull-request, and other session
+ operations generally rely on route authentication and a supplied session ID rather than creator or
+ participant ownership.
+- Sandbox credentials are verified against the Session Durable Object and cannot authenticate to a
+ different session. Child-sandbox fallbacks are also bound to their parent session.
+
+### Installation-wide resources
+
+The following resources are shared across admitted users in the current deployment model:
+
+| Resource | Read actions | Mutation or execution actions |
+| ------------------------ | ------------------------------------------- | ------------------------------------------------------------ |
+| Repository catalog | List repositories, branches, metadata | Use as session/environment/automation targets |
+| Global secrets | List key metadata | Create/update/delete values |
+| Repository secrets | List key metadata | Create/update/delete values |
+| Environments | List/view | Create/update/delete; manage repositories and branches |
+| Environment secrets | List key metadata | Create/update/delete/import values |
+| Integration settings | View global/repository/environment settings | Enable, update, override, reset |
+| SCM and sandbox settings | View configuration | Update/reset defaults and overrides |
+| Model preferences | View enabled models | Change installation-wide model visibility |
+| Provider accounts | List/status | Connect, reconnect, rename, verify, enable, disable, default |
+| Automations | List/view runs | Create, edit, trigger, pause, resume, delete, rotate key |
+| Managed shared skills | List/view | Import, edit, assign, reimport, delete |
+| MCP servers | List/view | Create, edit, delete commands, headers, and environment |
+| Image builds | View status/feed | Toggle prebuilds, trigger builds |
+| Commit signing | View metadata | Configure/update/delete signing material |
+| Analytics | View installation aggregates | No primary mutation workflow |
+
+Environments have no owner, member, team, role, or ACL columns. Repository access is based on the
+deployment's SCM App installation or configured token. Generic settings and secret stores are not
+keyed by user. Provider-account creator/updater IDs and automation creator fields record attribution
+but do not restrict later access.
+
+### Personal and local resources
+
+- Keyboard shortcut preferences are stored by canonical user ID.
+- Managed-skill profiles are associated with a canonical user, while the shared skill catalog is
+ installation-wide.
+- Session read states are stored by `(user_id, session_id)` but rely on the broad session visibility
+ boundary.
+- Provider-account device-authorization transactions are user-scoped while in progress; completed
+ provider accounts are installation-wide.
+- Appearance and syntax preferences are browser-local.
+- Slack and Linear bot preferences are provider-user-scoped in their Workers' KV stores.
+
+### Web application behavior
+
+- `AppAuthBoundary` gates the application shell on authentication state only.
+- The sidebar exposes new session, all/mine sessions, settings, automations, analytics, and archived
+ sessions to every authenticated user.
+- Settings navigation is identical for all authenticated users except for deployment-capability
+ checks such as repository-image support.
+- Session controls react to lifecycle, connection, and loading state, not participant role.
+- No client condition was found for an administrator flag, role, permission list, repository grant,
+ environment membership, session owner role, or creator equality.
+- The client does not currently represent an authenticated-but-forbidden state distinct from sign-in
+ admission denial, aside from generic API errors.
+
+## Relevant Workflows
+
+### Browser request
+
+1. GitHub or Google OAuth establishes a Better Auth browser session.
+2. The Next.js server signs the control-plane request as `service:web` and forwards the browser
+ cookie.
+3. The control plane verifies both channel and browser identity and creates a user principal.
+4. The route policy checks principal kind and SCM compatibility.
+5. The handler reads or mutates the requested resource; most handlers have no additional user-level
+ access check.
+
+### Bot-created session
+
+1. A bot verifies an external Slack, GitHub, or Linear webhook.
+2. The bot signs a control-plane request with its per-service secret and may assert the external
+ actor in its namespace.
+3. The control plane verifies the service and actor namespace, resolves or creates a canonical user,
+ and derives session identity from the principal.
+4. Session creation requires an actor-backed participant. Existing-session prompts may be actorless
+ and are then attributed to `anonymous`.
+5. The selected repository or environment is resolved using deployment-wide catalogs and
+ credentials. GitHub trigger flows additionally enforce configured allowlists or GitHub
+ write-level collaborator permissions; Slack and Linear do not perform equivalent SCM-user checks.
+
+### Session collaboration
+
+1. A browser or bot addresses a session by ID.
+2. A WebSocket-token or prompt request can create a `member` participant automatically.
+3. The Session Durable Object stores participants, messages, artifacts, diffs, repositories, sandbox
+ state, and credentials.
+4. Participant role is returned in shared session types, but the web does not consume it as an
+ authorization signal.
+
+### Sandbox runtime
+
+1. The control plane creates and hashes a per-session sandbox token.
+2. The token and session configuration are injected into the sandbox.
+3. Sandbox requests are authenticated against the session ID in the route.
+4. Session-bound routes broker SCM credentials, provider access, commit signing, skills,
+ attachments, and runtime events.
+5. The sandbox is not represented as a human role and cannot authenticate outside its bound session
+ through the sandbox credential.
+
+### Deployment and data plane
+
+1. GitHub Actions and Terraform provision Cloudflare, D1, R2, Workers, service secrets, and Modal.
+2. Deployment operators hold authority outside the application's principal model through source
+ control, GitHub environments, Cloudflare, Terraform state, Modal, and SCM App installation
+ access.
+3. The control plane authenticates to Modal with a deployment-wide HMAC secret.
+4. Modal trusts possession of that secret for authenticated endpoints and does not receive the
+ initiating application user, role, or resource grants.
+
+## Existing Patterns
+
+### Central authentication composition
+
+The router attaches a verified principal before authenticated handlers run. Route definitions carry
+typed authentication policy, and policy-completeness tests assert that every route declares one.
+
+### Canonical cross-provider identity
+
+Browser and bot identities converge on a canonical D1 user while retaining provider identity and
+participant identity. Body-supplied identity and credential fields are rejected for
+identity-sensitive routes.
+
+### Session-bound capabilities
+
+Sandbox tokens, image-build callback tokens, and browser participant WebSocket tokens are scoped to
+specific runtime resources rather than functioning as installation-wide human credentials.
+
+### Provider and scope registries
+
+Repositories use shared identity helpers, environments have opaque IDs and ordered repository
+membership, image builds use explicit repository/environment scope kinds, and integration settings
+already resolve global, repository, and environment levels.
+
+### Attribution without authorization
+
+Sessions, automations, provider accounts, skills, and logs record creators or actors. Existing code
+and design documents explicitly distinguish these fields from ownership checks.
+
+### Denial and audit behavior
+
+Authentication failures use `401`; principal-kind failures use `403`. Some sensitive workflows,
+including managed skills and Slack notification, emit structured audit logs. There is no complete,
+durable application authorization audit ledger.
+
+## Constraints and Invariants
+
+- TypeScript and Python use milliseconds and seconds respectively for durations.
+- Shared contracts are consumed by control plane, web, and bot packages and are built first.
+- D1 is the installation-wide relational store; each Session Durable Object has separate SQLite
+ state and is not directly joinable with D1 during an in-object operation.
+- Route authentication happens before handler execution; handler-authenticated webhooks apply their
+ own provider or capability checks.
+- Browser requests must retain both a signed web-service channel and a valid browser session.
+- Bot actors can only be asserted by their owning first-party service namespace.
+- Caller-supplied identity fields are rejected where verified principal identity is required.
+- Sandbox credentials remain session-bound and session provider-auth choices are immutable after
+ creation.
+- Repository owners may contain nested path segments; repository identity helpers split on the last
+ slash and preserve the complete owner.
+- Environment sessions snapshot repository membership; later environment changes do not alter
+ existing sessions.
+- Secrets are encrypted at rest and values are not returned by list operations, but authorization to
+ manage their ciphertext and metadata is installation-wide.
+- The Modal API receives a deployment credential, not end-user identity; application authorization
+ currently terminates at the control plane.
+- Existing admitted users have broad access under documented single-tenant semantics.
+
+## Known Gaps and Risks
+
+- No role, membership, grant, group, workspace, or administrator records exist in D1.
+- No authorization action vocabulary or resource-scope vocabulary exists in shared contracts.
+- Route policies conflate authentication channel, principal kind, SCM support, and broad route
+ access; handlers apply resource checks inconsistently.
+- `GITHUB_USER_OR_SERVICE_ROUTE` and similar policies often admit all signed services despite their
+ names.
+- Session `owner/member` roles do not define owner-exclusive actions and do not govern most access.
+- Session creator, provider-account creator, automation creator, and updater fields can be mistaken
+ for authorization ownership despite current attribution-only behavior.
+- The repository catalog reflects installation authority rather than authenticated-user grants.
+- A repository can belong to multiple environments, and environments can contain multiple
+ repositories; current data has no rules for combining access at those boundaries.
+- Bots differ in external authorization evidence. GitHub has repository permission checks in trigger
+ flows, while Slack and Linear rely primarily on webhook authenticity, configured mappings, and
+ deployment catalogs.
+- Service credentials provide broad route-family capabilities and are not generally constrained by
+ actor, creator, repository, or session.
+- The web exposes navigation and controls before knowing whether an action could be forbidden.
+- There is no complete durable record of allow/deny decisions, policy changes, role assignment, or
+ access revocation.
+- Existing tests primarily distinguish authenticated from unauthenticated requests, not multiple
+ human capability levels or cross-user denial.
+- Long-lived sessions, WebSockets, bot mappings, and sandboxes can outlast changes to human access;
+ current code has no access-revocation lifecycle because access grants do not exist.
+- External operator authority is outside the application and cannot be represented by current
+ principals.
+
+## Open Questions
+
+1. Does one Open-Inspect installation correspond permanently to one workspace, or can an
+ installation contain multiple independently administered organizations?
+2. Are application roles intended to be fixed built-in roles, configurable custom roles, or both?
+3. Which role bootstraps the first deployment administrator, and how is loss of all administrators
+ recovered?
+4. Are repository permissions inherited solely from an application role, assigned per user/group,
+ synchronized from SCM, or combined from those sources?
+5. Are environments independent authorization resources or derived from access to all, any, or the
+ primary member repository?
+6. Are sessions private to creators by default, visible to users with target access, or visible to
+ the whole workspace?
+7. Which session actions differ among creator, participant owner, participant member, repository
+ maintainer, and workspace administrator?
+8. Does adding a participant grant access, or merely record collaboration after another policy has
+ admitted access?
+9. Do automation runs and child sessions inherit access from the automation owner, triggering actor,
+ target resource, parent session, or a service identity?
+10. Which first-party services may read or mutate installation settings, secrets, provider accounts,
+ and arbitrary sessions?
+11. Do bots act with service-owned capabilities, the asserted human actor's capabilities, or an
+ intersection of both under the intended product semantics?
+12. How are actors without a linked canonical user handled when authorization requires user-level
+ grants?
+13. Is viewing secret key metadata distinct from writing or deleting secret values?
+14. Are analytics, user directories, audit records, and usage/cost data separate administrative
+ capabilities?
+15. Which role and grant changes must revoke active WebSockets, bot thread mappings, sandbox access,
+ or in-flight provider authorization transactions?
+16. Which authorization changes require historical audit retention, and for how long?
+17. Must existing admitted users preserve their current broad access when role records first appear?
+18. Are deployment operators expected to be application administrators, or are these intentionally
+ separate authority domains?
+
+## Evidence
+
+- `packages/control-plane/src/auth/principal.ts`: defines user, service, and sandbox principals and
+ service actor-namespace rights.
+- `packages/control-plane/src/auth/authenticate.ts`: composes signed web-service and browser-session
+ authentication.
+- `packages/control-plane/src/auth/identity-enforcement.ts`: derives actor identity and rejects
+ caller-supplied identity fields.
+- `packages/control-plane/src/auth/user/admission-policy.ts`: defines sign-in admission rules.
+- `packages/control-plane/src/db/user-store.ts`: canonicalizes provider identities into users.
+- `packages/control-plane/src/routes/shared.ts`: defines route authentication and SCM policies.
+- `packages/control-plane/src/router.ts`: attaches principals and enforces principal-kind policies.
+- `packages/control-plane/src/db/session-index.ts`: implements installation-wide session visibility.
+- `packages/control-plane/src/routes/session-index.ts`: lists and deletes sessions and stores
+ per-user read state.
+- `packages/control-plane/src/routes/session-runtime-proxy.ts`: exposes session runtime actions.
+- `packages/control-plane/src/routes/session-ws-token.ts`: mints participant WebSocket credentials.
+- `packages/control-plane/src/routes/session-prompt.ts`: derives prompt authors and allows automatic
+ session participation.
+- `packages/control-plane/src/session/schema.ts`: stores Session Durable Object participants and
+ runtime state.
+- `packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts`: checks
+ participation for selected lifecycle mutations.
+- `packages/shared/src/types/sessions.ts`: defines `owner/member` participant roles.
+- `packages/web/src/lib/browser-auth-session-contract.ts`: exposes browser user identity without
+ authorization data.
+- `packages/web/src/components/app-auth-boundary.tsx`: gates the application on authentication.
+- `packages/web/src/components/session-sidebar.tsx`: exposes shared navigation and All/Mine filters.
+- `packages/web/src/components/settings/settings-nav.tsx`: exposes installation settings without
+ user-role filtering.
+- `packages/control-plane/src/routes/repos.ts`: lists repositories using deployment SCM authority.
+- `packages/control-plane/src/routes/environments.ts`: exposes installation-wide environment CRUD.
+- `packages/control-plane/src/routes/secrets.ts`: exposes global and repository secret management.
+- `packages/control-plane/src/routes/environment-secrets.ts`: exposes environment secret management.
+- `packages/control-plane/src/routes/integration-settings.ts`: manages global, repository, and
+ environment settings.
+- `packages/control-plane/src/routes/model-provider-accounts.ts`: manages installation-wide provider
+ accounts with human-only authentication.
+- `packages/control-plane/src/routes/automations.ts`: exposes shared automation lifecycle actions.
+- `packages/control-plane/src/routes/skills.ts`: separates shared skill administration from per-user
+ profiles.
+- `packages/control-plane/src/routes/mcp-servers.ts`: exposes shared MCP server management.
+- `packages/control-plane/src/routes/analytics.ts`: exposes installation-wide analytics.
+- `terraform/d1/migrations/0019_create_users.sql`: creates canonical users and attribution columns.
+- `terraform/d1/migrations/0033_environments.sql`: creates environments without ownership or grants.
+- `terraform/d1/migrations/0055_session_read_states.sql`: creates per-user session read state.
+- `docs/HOW_IT_WORKS.md`: documents the single-tenant security and repository-access model.
+- `provider-accounts.md`: explicitly treats creator/updater fields as audit metadata and provider
+ accounts as installation-wide.
+- `packages/slack-bot/src/sessions/control-plane-client.ts`: sends signed Slack actor session calls.
+- `packages/github-bot/src/handlers.ts`: applies GitHub trigger and sender authorization checks.
+- `packages/linear-bot/src/webhook-handler.ts`: resolves Linear actors and session targets.
+- `packages/control-plane/src/sandbox/client.ts`: authenticates deployment-wide control-plane calls
+ to Modal.
+- `packages/control-plane/src/router.policy.test.ts`: checks route authentication policy coverage.
+- `packages/control-plane/test/integration/ws-token-participants.test.ts`: verifies automatic member
+ creation.
diff --git a/public/docs/internal/2026-08-30-session-access-research.md b/public/docs/internal/2026-08-30-session-access-research.md
new file mode 100644
index 000000000..7249aca01
--- /dev/null
+++ b/public/docs/internal/2026-08-30-session-access-research.md
@@ -0,0 +1,407 @@
+# Research: Session Access and Contribution
+
+**Date:** 2026-08-30 **Status:** Superseded current-state snapshot **Scope:** Session permission,
+relationship, participant, listing, and WebSocket behavior before workspace-wide session
+authorization was adopted.
+
+This document is intentionally research-only. It does not include recommendations, implementation
+plans, proposed code/API/schema changes, task breakdowns, estimates, or rollout steps.
+
+The accepted replacement is
+[Workspace-Wide Session Authorization](./2026-08-30-workspace-wide-session-authorization-design.md).
+
+## Summary
+
+The current system does not generally require a user to be a session creator or participant before
+they can read or contribute to a session. Built-in Members receive `sessions.read.any` and
+`sessions.collaborate.any`; Viewers receive `sessions.read.any`. These `any` permissions bypass the
+`session_access` relationship table entirely. An unrelated Member can therefore list, read, prompt,
+upload collaborative artifacts, and request a WebSocket token for any workspace session.
+
+`session_access` remains active in narrower workflows. It gates Member lifecycle and sandbox access,
+requires creator status for Member deletion and participant management, supports custom roles that
+hold only `.own` permissions, filters own-scoped lists, and constrains every actor-backed bot call
+because service actors are forced to `own` scope. WebSocket subscription also consults it when the
+user's collaboration permission resolves to `.own`.
+
+The system also has a separate Session Durable Object `participants` table. It stores session-local
+identity, SCM metadata, WebSocket tokens, presence identity, and an `owner` or `member` role. It is
+not the authority used by `requireSession`, but title, archive, and unarchive still require the
+caller to exist in that table. D1 relationships and Durable Object participants can therefore
+diverge and have different effects.
+
+The resulting complexity represents several different concerns under similar terminology rather than
+one uniform contribution boundary.
+
+## Research Questions
+
+1. Does session access currently restrict who can read or contribute to a session?
+2. Which operations still depend on creator or participant relationships?
+3. What does `requireSession` enforce for humans, services, and sandboxes?
+4. How do D1 `session_access` and Durable Object participants differ?
+5. Which current behaviors and documents are inconsistent or ambiguous?
+
+## Current Behavior
+
+### Built-in role behavior
+
+The built-in role registry gives Members these session permissions:
+
+- `sessions.read.any`
+- `sessions.collaborate.any`
+- `sessions.lifecycle.own`
+- `sessions.participants.manage.own`
+- `sessions.delete.own`
+- `sessions.sandbox_access.own`
+
+Viewers receive `sessions.read.any` and no contribution or lifecycle permission. Administrators and
+Owners receive the `any` form of every session operation.
+
+`resolveScopedPermission()` selects `any` before `own`. The router does not query a session
+relationship after resolving `any`.
+
+Consequences for a built-in Member:
+
+| Operation | Existing relationship required? | Current basis |
+| ------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------- |
+| List sessions | No | `sessions.read.any` |
+| Read session state, messages, artifacts, media, diffs, and children | No | `sessions.read.any` |
+| Submit an HTTP prompt | No | `sessions.collaborate.any` |
+| Request a WebSocket token | No | `sessions.collaborate.any` |
+| Upload attachments, media, or diffs | No | `sessions.collaborate.any` |
+| Create a pull request or child session | No prior relationship | `sessions.collaborate.any`, plus operation-specific requirements |
+| Stop, rename, archive, unarchive, refresh, or retry | Yes | `sessions.lifecycle.own` |
+| Obtain sandbox credentials | Yes | `sessions.sandbox_access.own` |
+| Delete a session | Creator only | `sessions.delete.own` |
+| Manage participants | Creator only | `sessions.participants.manage.own` |
+
+An Administrator or Owner bypasses these relationship requirements through the corresponding `*.any`
+permission at the router layer.
+
+### Operation-to-relationship mapping
+
+`session-authorization-policy.ts` maps each operation to both a permission stem and an own-scope
+relationship:
+
+| Operation | Permission stem | Relationship under `.own` |
+| ---------------------- | ------------------------------ | ------------------------- |
+| Read | `sessions.read` | Creator or participant |
+| Collaborate | `sessions.collaborate` | Creator or participant |
+| Lifecycle | `sessions.lifecycle` | Creator or participant |
+| Participant management | `sessions.participants.manage` | Creator |
+| Sandbox access | `sessions.sandbox_access` | Creator or participant |
+| Delete | `sessions.delete` | Creator |
+
+The term `own` therefore has two meanings in current policy. For four operations it means any access
+relationship; for deletion and participant management it means creator.
+
+### `requireSession`
+
+`requireSession(operation, sessionIdParam)` creates an active-user route policy with one session
+requirement. At request admission, the router:
+
+1. Loads the effective authorization for the human user or represented service actor.
+2. Rejects suspended users and missing role assignments.
+3. Resolves the operation's `any` or `own` permission.
+4. Applies the signed service's capability ceiling.
+5. Forces signed service actors to `own` scope.
+6. Queries `session_access` only when the resulting scope is `own`.
+
+Relationship failures return `session_access_required` or `creator_required` with HTTP 403.
+Unexpected authorization storage failures return `authorization_unavailable` with HTTP 503.
+
+For sandbox-fallback routes, `requireSession` describes the user/service path. A verified sandbox
+principal does not have a workspace user authorization and bypasses these RBAC requirements. Its
+authority comes from the sandbox token being bound to the route's session ID.
+
+### D1 `session_access`
+
+Migration 0071 defines one canonical relationship per session and workspace user:
+
+```sql
+CREATE TABLE session_access (
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ relation TEXT NOT NULL CHECK (relation IN ('creator', 'participant')),
+ PRIMARY KEY (session_id, user_id)
+);
+```
+
+The table contains no activity state, timestamps, invitation source, participant identifier, or
+WebSocket state.
+
+Creator rows are inserted with the D1 session index. Migration 0071 backfills canonical historical
+creators. Participant rows are inserted after:
+
+- successful public WebSocket-token issuance;
+- successful public participant addition.
+
+Participant activation uses `ON CONFLICT DO NOTHING`, so an existing creator row is never downgraded
+to participant.
+
+There is no production participant-removal route or D1 deactivation helper. Relationship deletion
+currently occurs through session/user cascade, user merge, test setup, or direct database activity.
+
+### Session Durable Object participants
+
+The Session Durable Object has a separate `participants` table containing:
+
+- a session-local participant ID;
+- a provider/session-local `user_id`;
+- an optional canonical D1 `canonical_user_id`;
+- SCM identity and credentials;
+- `owner` or `member` role;
+- WebSocket token hash and issuance time;
+- join time.
+
+Session initialization creates an owner participant. WebSocket-token issuance creates or enriches a
+member participant. API prompt enqueue also creates a missing participant.
+
+The DO `owner` or `member` value is not read by `requireSession`. Canonical creator authority comes
+from D1 `session_access.relation = 'creator'`. The DO role is returned in participant responses and
+persists as session-local state.
+
+Title, archive, and unarchive differ from other lifecycle routes: after router authorization, their
+DO handlers also require the acting identity to exist in the local participants table. Stop, pull
+request refresh, diff retry, and child cancellation do not share that second participant-existence
+check.
+
+### Contribution paths
+
+HTTP prompt admission uses `requireSession("collaborate")`. For a built-in Member this resolves to
+`collaborate.any`, so no relationship is required. The DO creates a participant when the prompt
+author is not already present, but this prompt path does not create a D1 `session_access` row.
+
+WebSocket-token issuance also uses `collaborate`. A successful token response creates both a DO
+participant and a D1 participant relationship. This means the common browser join flow establishes
+the relationship after open collaboration has already authorized the join.
+
+Once a browser WebSocket subscribes successfully, prompt, cancel, stop, history, typing, and
+presence messages use the authenticated client and its authorization lease. Individual WebSocket
+commands do not independently resolve read, collaborate, or lifecycle permissions.
+
+### WebSocket authorization
+
+The initial WebSocket upgrade verifies only that the session exists. The socket remains
+unauthenticated until it sends a subscription token.
+
+Subscription verifies:
+
+- the token hash maps to a DO participant;
+- the participant has a canonical user ID;
+- the canonical user is active and assigned;
+- current `sessions.collaborate` permission;
+- D1 access when collaboration scope is `.own`;
+- the 24-hour token lifetime.
+
+A successful subscription receives a five-minute authorization lease. During that lease, permission
+and relationship changes are not continuously queried. Expiry closes the socket and a later
+subscription evaluates current authorization again.
+
+For the built-in Member's `collaborate.any`, subscription does not require the D1 relationship. For
+custom roles with only `collaborate.own`, removing the relationship causes a later subscription to
+fail.
+
+### Lists and displayed capabilities
+
+Session list and inbox SQL use `sessionAccessPredicate()` only when read scope is `own`. For scope
+`any`, the predicate is `1 = 1`.
+
+Because Member and Viewer use `read.any`, their ordinary lists are workspace-wide. The `Mine` filter
+is separate: it filters `sessions.user_id`, which is creator attribution rather than an
+authorization relationship.
+
+At the time of this research, lists also computed `canManageLifecycle` from the caller's lifecycle
+scope and relationship. The workspace-wide authorization implementation later removed that response
+field; the web client now derives lifecycle-control visibility from current-user permissions, while
+lifecycle endpoints perform their own request admission.
+
+### Services and bots
+
+Signed services use the represented canonical actor's role, a hard-coded service capability ceiling,
+and a forced `own` session scope. A bot actor therefore needs a D1 creator or participant
+relationship even when that actor's built-in Member role contains `read.any` and `collaborate.any`.
+
+This produces a contribution boundary for bot actors that does not exist for browser Members. An
+unrelated Slack actor is denied when prompting another actor's session with
+`session_access_required`.
+
+No session route currently declares an actorless service grant. Several bot call sites issue
+actorless session requests, including Slack attachment/media operations and Linear stop/event
+operations. Central route admission rejects such requests with `service_actor_required` before
+session relationship evaluation.
+
+### Child sessions
+
+User/service child creation requires `sessions.create` and collaboration on the parent. A parent
+sandbox token can create a child through the sandbox capability path without user RBAC.
+
+The child creator is the parent session's active prompt author. Parent access does not automatically
+create child access for a different parent creator. User/service child read and cancellation are
+authorized against the child, while the parent sandbox path authenticates against the parent and
+then checks parent-child lineage in the handler.
+
+## Relevant Workflows
+
+### Browser Member joins an unrelated session
+
+1. Session list is visible through `sessions.read.any`.
+2. Session read is admitted without `session_access`.
+3. WebSocket-token request is admitted through `sessions.collaborate.any`.
+4. The DO creates or updates a participant and rotates its token.
+5. The control plane inserts D1 participant access.
+6. Subscription rechecks collaboration and grants a five-minute lease.
+7. The participant relationship now satisfies Member lifecycle-own and sandbox-access-own.
+
+### HTTP prompt without WebSocket token
+
+1. Prompt request is admitted through `sessions.collaborate.any` for a Member.
+2. The DO creates a missing participant and enqueues the prompt.
+3. No D1 participant relationship is created by this path.
+4. Later lifecycle-own or sandbox-access-own checks still depend on another path having created D1
+ access.
+
+### Actor-backed bot contribution
+
+1. The service signature identifies the service and represented actor.
+2. The actor's current workspace authorization is loaded.
+3. The service ceiling is applied.
+4. Session scope is forced to `own`.
+5. The actor must already have creator or participant D1 access.
+
+### Administrator lifecycle request without joining
+
+1. `sessions.lifecycle.any` passes router admission without D1 access.
+2. Stop, refresh, and retry can proceed without a DO participant check.
+3. Title, archive, and unarchive query the DO participant table and return 403 when the identity is
+ absent.
+
+## Existing Patterns
+
+- Workspace permissions and session relationships are evaluated in the control-plane router.
+- The D1 relationship projection uses canonical workspace user IDs.
+- The Session DO participant table owns session-local attribution, SCM metadata, tokens, and
+ connection identity.
+- Open collaboration is expressed by built-in `*.any` permissions rather than an exception inside
+ relationship code.
+- Service actors are intentionally narrowed to `own` regardless of their human role's `any` grant.
+- Sandbox principals use possession of a session-bound capability instead of workspace RBAC.
+- WebSocket authorization is evaluated at subscription and represented by a bounded lease.
+- Session list authorization and lifecycle capability are calculated in SQL before results are
+ returned.
+
+## Constraints and Invariants
+
+- One canonical user has at most one D1 relationship per session.
+- Creator access is not replaced by participant activation.
+- Own-scoped deletion and participant management require creator relation.
+- Other own-scoped operations accept creator or participant relation.
+- Any-scoped operations do not consult `session_access`.
+- Actor-backed services cannot use any-scoped session access.
+- A sandbox token is valid only for its bound session route.
+- Successful WebSocket subscription requires a canonical user identity.
+- WebSocket authorization is bounded by a five-minute lease and token use by a 24-hour lifetime.
+- D1 and Session DO writes do not share a cross-store transaction.
+- User merge preserves the strongest D1 relationship when creator and participant rows collide.
+
+## Known Gaps and Risks
+
+### Relationship and participant divergence
+
+The two stores have different writers and no reconciliation workflow:
+
+- API prompt creates a DO participant without D1 access.
+- DO success followed by D1 activation failure leaves a DO participant without D1 access.
+- D1 user merge rewrites access but does not update existing DO canonical participant identities.
+- There is no participant-removal flow spanning D1, DO tokens, presence, or existing sockets.
+- DO `owner/member` and D1 `creator/participant` can disagree.
+
+### Inconsistent lifecycle enforcement
+
+Title, archive, and unarchive require local DO participant existence after router authorization.
+Other lifecycle endpoints do not. This makes `sessions.lifecycle.any` behavior dependent on the
+specific endpoint and whether the caller previously joined the session.
+
+### Contribution does not uniformly establish access
+
+WebSocket-token contribution establishes D1 participant access; direct HTTP prompting does not. Both
+can establish a DO participant.
+
+### Service-call mismatches
+
+Some bot call sites omit actors for routes whose central policy requires one. Package-local tests
+mock the control plane and do not cover these calls through real central authorization.
+
+### Documentation drift
+
+The RBAC design includes mutually inconsistent statements about Member visibility. Its role matrix
+describes open Member read/collaboration, while other sections describe Member lists as
+creator/participant filtered. It also documents participant removal that is not implemented and
+states that the DO has no local owner role even though that field remains in schema and runtime
+behavior.
+
+### Test coverage boundaries
+
+Existing tests cover scoped permission resolution, relationship checks, list filtering, WebSocket
+subscription, service actor isolation, creator-only deletion, and projection writes. No
+comprehensive role-by-operation HTTP matrix or end-to-end test of active WebSocket authorization
+changes across a lease boundary was found.
+
+## Open Questions
+
+1. Is `session_access` intended to represent durable membership, a capability projection, or only
+ the relationship input for `.own` permissions?
+2. Is open Member contribution intended to establish membership, or is the relationship created by
+ WebSocket-token issuance incidental to the current browser workflow?
+3. Is direct HTTP prompt participation intentionally excluded from D1 participant activation?
+4. Are the DO participant checks on title, archive, and unarchive intentional authorization or
+ residual pre-RBAC behavior?
+5. Does actor-backed service isolation intentionally differ from open browser Member collaboration?
+6. Are DO `owner/member` roles still part of supported session semantics, or only retained state for
+ compatibility and presentation?
+7. Was participant removal deliberately excluded from the current product surface?
+8. Is parent-to-child access intentionally independent when the active prompt author differs from
+ the parent creator?
+9. Are the RBAC design documents historical artifacts, living documentation, or a mixture of both?
+
+## Evidence
+
+- `packages/shared/src/rbac.ts`: built-in role permission sets and any-before-own scope resolution.
+- `packages/control-plane/src/authorization/session-authorization-policy.ts`:
+ operation-to-permission and operation-to-relationship mapping.
+- `packages/control-plane/src/routes/shared.ts`: `requireSession` route metadata construction.
+- `packages/control-plane/src/router.ts`: active-user, service-ceiling, scoped-permission, and
+ relationship enforcement.
+- `packages/control-plane/src/db/session-access.ts`: list predicate, exact relationship check, and
+ participant activation.
+- `terraform/d1/migrations/0071_rbac_foundation.sql`: relationship schema, index, and creator
+ backfill.
+- `packages/control-plane/src/db/session-index.ts`: creator insertion, own-scoped listing, and
+ lifecycle capability projection.
+- `packages/control-plane/src/db/session-inbox-store.ts`: inbox visibility and lifecycle capability.
+- `packages/control-plane/src/routes/session-ws-token.ts`: public token issuance and D1 participant
+ activation.
+- `packages/control-plane/src/routes/session-prompt.ts`: collaboration admission and
+ principal-derived prompt identity.
+- `packages/control-plane/src/session/message-queue.ts`: prompt-created DO participants.
+- `packages/control-plane/src/session/schema.ts`: DO participant schema and owner/member role.
+- `packages/control-plane/src/session/connection-authenticator.ts`: WebSocket token, canonical user,
+ authorization, and token-age checks.
+- `packages/control-plane/src/session/websocket-manager.ts`: lease persistence, lookup, and expiry.
+- `packages/control-plane/src/session/http/handlers/session-lifecycle.handler.ts`: residual DO
+ participant checks for title/archive/unarchive.
+- `packages/control-plane/src/authorization/service-permissions.ts`: bot service capability
+ ceilings.
+- `packages/control-plane/test/integration/rbac-routes.test.ts`: open Member lists and creator-only
+ deletion.
+- `packages/control-plane/test/integration/websocket-client.test.ts`: any/own collaboration,
+ relationship loss, suspension, and assignment failure behavior.
+- `packages/control-plane/test/integration/service-auth.test.ts`: actor-backed service relationship
+ isolation.
+- `packages/control-plane/test/integration/d1-session-index.test.ts`: creator projection, missing
+ projection, and lifecycle capability behavior.
+- `packages/control-plane/test/integration/user-merge.test.ts`: relationship collision precedence.
+- `public/docs/internal/2026-08-28-rbac-design.md`: stated RBAC model and observed documentation
+ contradictions.
+- Git commit `69d32c6`: changed Member read and collaboration from own to any while retaining the
+ relationship projection for narrower operations.
diff --git a/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md b/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md
new file mode 100644
index 000000000..5934a94d1
--- /dev/null
+++ b/public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md
@@ -0,0 +1,193 @@
+# Design: Workspace-Wide Session Authorization
+
+**Date:** 2026-08-30
+
+**Status:** Accepted
+
+**Research:** [2026-08-30-session-access-research.md](./2026-08-30-session-access-research.md)
+
+## Summary
+
+Open-Inspect sessions are workspace-wide resources. An active user may perform an operation on every
+session when their workspace role grants that operation. Session creator and participant
+relationships do not grant, narrow, or revoke authorization.
+
+Session authorization uses unscoped operation permissions. Actor-backed bot requests intersect the
+represented user's current role with the bot service's fixed capability ceiling, without applying a
+session relationship check.
+
+Creator attribution, participant identity, sandbox capability binding, and WebSocket authorization
+remain supported concerns, but none is a session access-control list.
+
+## Context
+
+Before workspace RBAC, authenticated users could operate across sessions without a creator or
+participant authorization boundary. The RBAC foundation introduced `.own` and `.any` session
+permission pairs and a D1 `session_access` projection. Built-in Members still received
+workspace-wide read and collaboration, while lifecycle, sandbox access, deletion, participant
+management, and bot requests became relationship-dependent.
+
+That partial relationship model does not match the product's multiplayer behavior. It also creates
+two inconsistent participant stores: D1 relationships used for authorization and Session Durable
+Object participants used for message identity, presence, SCM metadata, and WebSocket tokens.
+Different contribution paths update those stores differently.
+
+## Decisions
+
+### Workspace-wide operations
+
+Session permissions are operation permissions without resource scope:
+
+- `sessions.read`
+- `sessions.collaborate`
+- `sessions.create`
+- `sessions.lifecycle`
+- `sessions.sandbox_access`
+- `sessions.delete`
+
+A granted session operation applies to every session in the workspace. No route or WebSocket
+authorization check consults creator or participant relationships.
+
+Deletion is workspace-scoped. Creator-only deletion is explicitly deferred and is not part of this
+RBAC change.
+
+### Built-in roles
+
+Built-in roles distinguish which operations a user may perform, not which sessions they may target:
+
+| Role | Session behavior |
+| ------------- | ----------------------------------------------------------------------------------- |
+| Owner | Every session operation across the workspace. |
+| Administrator | Every session operation across the workspace. |
+| Member | Create, read, collaborate, manage lifecycle, access sandboxes, and delete sessions. |
+| Viewer | Read every session; no create, collaborate, lifecycle, sandbox, or delete access. |
+
+Custom roles may contain any registered session operation permission. Custom roles cannot express
+private, invitation-only, creator-only, or participant-only session access.
+
+### Actor-backed services
+
+A bot service acting for a human uses the intersection of two operation sets:
+
+```text
+effective operations = actor role permissions intersect service capability ceiling
+```
+
+The represented actor must resolve to an active canonical workspace user. The service cannot exceed
+the actor's role or its own ceiling. If both grant `sessions.collaborate`, the actor may collaborate
+on any session, including a session created by another user. This preserves multiplayer Slack,
+GitHub, and Linear workflows.
+
+Actorless service calls remain limited to narrow route-specific grants.
+
+### Creator attribution
+
+`sessions.user_id` records the canonical user responsible for creating a session. It supports
+display, filtering, auditing, credential selection, automation lineage, and other attribution needs.
+It is not an authorization relationship.
+
+The `Mine` session-list filter continues to select sessions by creator attribution. It is a user
+filter, not an access boundary.
+
+### Participant identity
+
+Session Durable Object participants identify message authors and connected clients. They may retain:
+
+- provider identity and canonical user linkage;
+- display and SCM metadata;
+- message attribution;
+- presence identity;
+- WebSocket token ownership.
+
+Participant existence and the persisted `owner` or `member` value do not authorize session
+operations. Joining or contributing to a session does not create a separate authorization grant.
+
+Participant-management APIs that exist only to maintain access-control relationships are removed.
+Runtime participant creation required for attribution remains internal to contribution and
+WebSocket-token flows.
+
+### WebSockets
+
+WebSocket token issuance and subscription require an active canonical user with
+`sessions.collaborate`. Tokens remain bound to their session and participant identity. Subscription
+authorization is rechecked through bounded leases so suspension or role changes affect live access.
+
+The authorization recheck evaluates active workspace membership and `sessions.collaborate`; it does
+not evaluate creator or participant access records.
+
+### Sandbox capabilities
+
+Human or actor-backed requests for sandbox credentials require `sessions.sandbox_access`, which
+applies workspace-wide. Sandbox-originated control-plane requests continue to authenticate with a
+session-bound sandbox capability and remain restricted to that session.
+
+Human workspace authorization and sandbox capability binding are separate security boundaries.
+
+### Lifecycle and state checks
+
+Lifecycle routes require `sessions.lifecycle` for every session. Session state-machine checks,
+queued-work checks, and sandbox runtime constraints continue to apply.
+
+Durable Object participant existence is not a lifecycle authorization condition. Rename, archive,
+and unarchive follow the same workspace permission policy as stop, retry, and refresh.
+
+### Service and UI metadata
+
+Session lists are not filtered by authorization relationships. Query filters such as creator and
+status remain supported.
+
+The web client derives lifecycle-control visibility from the current user's workspace
+`sessions.lifecycle` permission. Session list and inbox responses contain session data, not
+authorization presentation metadata; lifecycle endpoints remain authoritative.
+
+## Removed Model
+
+The RBAC foundation does not include:
+
+- a D1 `session_access` table;
+- creator or participant authorization projections;
+- `.own` and `.any` session permission pairs;
+- relationship-filtered session or inbox queries;
+- relationship activation during WebSocket token issuance;
+- relationship-aware user merge behavior;
+- creator-only deletion or participant management;
+- bot-specific narrowing to sessions associated with the represented actor.
+
+Because this schema and permission model were introduced on the unshipped RBAC branch, they are
+removed directly from the branch migration and permission registry rather than retained as a
+compatibility layer.
+
+## Deferred Features
+
+Private, invitation-only, creator-restricted, or participant-restricted sessions require a separate
+product design. Such a design must address visibility, invitations, removal, revocation, historical
+participants, bot behavior, parent-child sessions, cross-store consistency, migration, and UI.
+
+No relationship schema or permission identifiers are retained speculatively for that future work.
+
+## Invariants
+
+- A workspace permission has the same meaning for browser users and represented bot actors.
+- A service may narrow an actor's operations but may not expand them.
+- Session creator and participant data are attribution and runtime identity, not authorization.
+- Every user with `sessions.read` can read and list every session.
+- Every user with `sessions.collaborate` can contribute to every session.
+- Every user with `sessions.lifecycle` can invoke lifecycle operations on every session.
+- Every user with `sessions.sandbox_access` can request sandbox access for every session.
+- Every user with `sessions.delete` can delete every session.
+- Sandbox credentials remain bound to one session regardless of human workspace permissions.
+- Suspension and role changes apply to new HTTP requests and bounded-lifetime WebSocket leases.
+
+## Verification
+
+The implementation must cover:
+
+- a role-by-operation HTTP authorization matrix;
+- cross-user browser collaboration;
+- cross-user actor-backed bot listing and collaboration;
+- service ceiling denial when the actor role permits an operation the service does not;
+- Viewer read access and mutation denial;
+- workspace-wide lifecycle, sandbox, and deletion behavior for permitted roles;
+- WebSocket subscription reauthorization after role or suspension changes;
+- session-bound sandbox authentication;
+- lifecycle consistency across rename, archive, unarchive, stop, retry, and refresh.
From a1a5858361a3f74cda858e56d6335528bd4f1148 Mon Sep 17 00:00:00 2001
From: Cole Murray
Date: Mon, 31 Aug 2026 01:39:07 -0700
Subject: [PATCH 11/11] fix: address permission-aware UI review feedback
---
.../control-plane/src/router.policy.test.ts | 6 +-
packages/control-plane/src/router.ts | 14 +-
.../src/routes/session-runtime-proxy.test.ts | 74 +-
.../src/routes/session-runtime-proxy.ts | 44 +-
.../src/routes/session-ws-token.test.ts | 2 +-
.../src/routes/session-ws-token.ts | 7 +-
.../src/sandbox/lifecycle/manager.test.ts | 76 +-
.../src/sandbox/lifecycle/manager.ts | 4 +-
.../src/session/client-command-facade.ts | 8 +
.../control-plane/src/session/components.ts | 19 +-
.../src/session/connection-authenticator.ts | 56 +-
.../src/session/message-router.ts | 27 +
.../src/session/sandbox-access-reader.ts | 14 +-
.../control-plane/src/session/server.test.ts | 25 +
.../test/integration/session-snapshot.test.ts | 2 +
.../test/integration/websocket-client.test.ts | 32 +-
.../integration/websocket-sandbox.test.ts | 2 +
packages/shared/src/rbac.test.ts | 6 +
packages/shared/src/rbac.ts | 18 +-
.../shared/src/types/server-messages.test.ts | 31 +-
packages/shared/src/types/server-messages.ts | 11 +
.../app/(app)/(sidebar)/session/[id]/page.tsx | 53 +-
.../web/src/components/action-bar.test.tsx | 27 +-
packages/web/src/components/action-bar.tsx | 6 +-
.../src/components/diff-retry-notice.test.tsx | 48 +-
.../web/src/components/diff-retry-notice.tsx | 7 +-
.../src/components/mobile-session-actions.tsx | 7 +-
.../components/queued-prompt-stack.test.tsx | 15 +-
.../src/components/queued-prompt-stack.tsx | 7 +-
.../web/src/components/session-actions.ts | 3 +-
.../components/session-changes-panel.test.tsx | 14 +
.../src/components/session-changes-panel.tsx | 7 +-
.../components/session-details-overlay.tsx | 8 +-
.../src/components/session-header.test.tsx | 32 +-
.../web/src/components/session-header.tsx | 23 +-
.../session-prompt-composer.test.tsx | 21 +
.../components/session-prompt-composer.tsx | 7 +-
.../components/session-right-sidebar.test.tsx | 11 +-
.../src/components/session-right-sidebar.tsx | 29 +-
.../web/src/components/session-sidebar.tsx | 1 -
.../sidebar/metadata-section.test.tsx | 12 +-
.../components/sidebar/metadata-section.tsx | 4 +-
packages/web/src/hooks/use-sandbox-access.ts | 8 +-
.../web/src/hooks/use-session-socket.test.tsx | 122 ++-
packages/web/src/hooks/use-session-socket.ts | 8 +-
.../src/hooks/use-session-transport.test.tsx | 24 +
.../web/src/hooks/use-session-transport.ts | 24 +-
.../web/src/lib/automation-authorization.ts | 9 +-
packages/web/src/lib/session-capabilities.ts | 20 +
.../docs/internal/2026-08-28-rbac-design.md | 815 ------------------
.../docs/internal/2026-08-28-rbac-research.md | 386 ---------
.../2026-08-30-session-access-research.md | 407 ---------
...space-wide-session-authorization-design.md | 193 -----
53 files changed, 777 insertions(+), 2059 deletions(-)
create mode 100644 packages/web/src/lib/session-capabilities.ts
delete mode 100644 public/docs/internal/2026-08-28-rbac-design.md
delete mode 100644 public/docs/internal/2026-08-28-rbac-research.md
delete mode 100644 public/docs/internal/2026-08-30-session-access-research.md
delete mode 100644 public/docs/internal/2026-08-30-workspace-wide-session-authorization-design.md
diff --git a/packages/control-plane/src/router.policy.test.ts b/packages/control-plane/src/router.policy.test.ts
index 2a0ad18f2..d5e1b4d91 100644
--- a/packages/control-plane/src/router.policy.test.ts
+++ b/packages/control-plane/src/router.policy.test.ts
@@ -154,11 +154,7 @@ describe("route policy table", () => {
});
expect(routeFor("POST", "/sessions/session-1/ws-token")?.authorization).toMatchObject({
kind: "active-user",
- allOf: [
- { kind: "permission", permission: "sessions.read" },
- { kind: "permission", permission: "sessions.collaborate" },
- { kind: "permission", permission: "sessions.lifecycle" },
- ],
+ allOf: [{ kind: "permission", permission: "sessions.read" }],
});
expect(routeFor("POST", "/sessions/session-1/stop")?.authorization).toMatchObject({
service: { kind: "actor", actorlessGrants: [{ service: "linear-bot" }] },
diff --git a/packages/control-plane/src/router.ts b/packages/control-plane/src/router.ts
index e13e0a4ce..e06a57687 100644
--- a/packages/control-plane/src/router.ts
+++ b/packages/control-plane/src/router.ts
@@ -19,7 +19,11 @@ import { UserStore } from "./db/user-store";
import { AutomationStore } from "./db/automation-store";
import { AuthorizationError, AuthorizationService } from "./authorization/service";
import { serviceAllowsPermission } from "./authorization/service-permissions";
-import { SCOPED_PERMISSION_PAIRS, resolveScopedPermission } from "@open-inspect/shared/rbac";
+import {
+ SCOPED_PERMISSION_PAIRS,
+ hasScopedPermission,
+ resolveScopedPermission,
+} from "@open-inspect/shared/rbac";
import { createLogger } from "./logger";
import type { BackgroundTasks } from "./platform-ports";
import {
@@ -478,11 +482,13 @@ async function enforceAutomationRequirement(
const automation = await store.resolveCanonicalOwner(storedAutomation);
const permissionStem = `automations.${requirement.operation}` as const;
- const permissionScope = resolveScopedPermission(permissionStem, authorization.permissions);
const ownPermission = SCOPED_PERMISSION_PAIRS[permissionStem].own;
if (
- !permissionScope ||
- (permissionScope === "own" && automation.user_id !== ctx.principal.userId)
+ !hasScopedPermission(
+ permissionStem,
+ authorization.permissions,
+ automation.user_id === ctx.principal.userId
+ )
) {
return json(
{ error: "Forbidden", code: "permission_required", permission: ownPermission },
diff --git a/packages/control-plane/src/routes/session-runtime-proxy.test.ts b/packages/control-plane/src/routes/session-runtime-proxy.test.ts
index 62688ce26..9ecb62ae2 100644
--- a/packages/control-plane/src/routes/session-runtime-proxy.test.ts
+++ b/packages/control-plane/src/routes/session-runtime-proxy.test.ts
@@ -1,12 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { SessionInternalPaths } from "../session/contracts";
+import type { PermissionId } from "@open-inspect/shared/rbac";
import type { RequestContext } from "./shared";
import type { SqlDatabase } from "../db/sql-database";
import { sessionRuntimeProxyRoutes } from "./session-runtime-proxy";
import type { Env } from "../types";
import { TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support";
-function createCtx(db: SqlDatabase = {} as SqlDatabase): RequestContext {
+function createCtx(
+ db: SqlDatabase = {} as SqlDatabase,
+ permissions: PermissionId[] = ["sessions.read"]
+): RequestContext {
return {
trace_id: "trace-1",
request_id: "req-1",
@@ -16,6 +20,12 @@ function createCtx(db: SqlDatabase = {} as SqlDatabase): RequestContext {
kind: "user",
userId: "user-1",
},
+ authorization: {
+ userId: "user-1",
+ suspendedAt: null,
+ role: { id: "role-1", key: "viewer", name: "Viewer" },
+ permissions,
+ },
metrics: {
d1Queries: [],
spans: {},
@@ -44,15 +54,13 @@ function getHandler(method: string, path: string) {
}
describe("session runtime proxy routes", () => {
- it.each([
- ["snapshot", "/sessions/session-1", SessionInternalPaths.snapshot],
- ["sandbox access", "/sessions/session-1/sandbox-access", SessionInternalPaths.sandboxAccess],
- ])("forwards %s for users", async (_name, path, internalPath) => {
+ it("forwards sandbox access for users", async () => {
const requests: Request[] = [];
const fetch = vi.fn(async (request: Request) => {
requests.push(request);
return Response.json({ sessionId: "session-1" });
});
+ const path = "/sessions/session-1/sandbox-access";
const { handler, match } = getHandler("GET", path);
const response = await handler(
@@ -63,10 +71,64 @@ describe("session runtime proxy routes", () => {
);
expect(response.status).toBe(200);
- expect(new URL(requests[0].url).pathname).toBe(internalPath);
+ expect(new URL(requests[0].url).pathname).toBe(SessionInternalPaths.sandboxAccess);
expect(fetch).toHaveBeenCalledOnce();
});
+ it.each([
+ { permissions: ["sessions.read"] as PermissionId[], exposed: false },
+ {
+ permissions: ["sessions.read", "sessions.sandbox_access"] as PermissionId[],
+ exposed: true,
+ },
+ ])("scopes snapshot sandbox locations to sandbox access ($exposed)", async (input) => {
+ const fetch = vi.fn(async () =>
+ Response.json({
+ session: {
+ id: "session-1",
+ title: "Session",
+ repoOwner: "acme",
+ repoName: "web",
+ baseBranch: "main",
+ branchName: "feature",
+ status: "active",
+ sandboxStatus: "ready",
+ messageCount: 0,
+ createdAt: 1,
+ codeServerUrl: "https://code.example",
+ vncUrl: "https://vnc.example",
+ ttydUrl: "https://terminal.example",
+ tunnelUrls: { "3000": "https://app.example" },
+ sandboxDashboardUrl: "https://provider.example",
+ },
+ artifacts: [],
+ promptQueue: [],
+ timeline: { events: [], hasMore: false, cursor: null },
+ })
+ );
+ const path = "/sessions/session-1";
+ const { handler, match } = getHandler("GET", path);
+
+ const response = await handler(
+ new Request(`https://test.local${path}`),
+ createEnv(fetch),
+ match,
+ createCtx({} as SqlDatabase, input.permissions)
+ );
+ const snapshot = (await response.json()) as { session: Record };
+
+ expect(response.status).toBe(200);
+ if (input.exposed) {
+ expect(snapshot.session).toHaveProperty("codeServerUrl", "https://code.example");
+ } else {
+ expect(snapshot.session).not.toHaveProperty("codeServerUrl");
+ expect(snapshot.session).not.toHaveProperty("vncUrl");
+ expect(snapshot.session).not.toHaveProperty("ttydUrl");
+ expect(snapshot.session).not.toHaveProperty("tunnelUrls");
+ expect(snapshot.session).not.toHaveProperty("sandboxDashboardUrl");
+ }
+ });
+
it("forwards event query strings through the session runtime dependency", async () => {
const requests: Request[] = [];
const fetch = vi.fn(async (request: Request) => {
diff --git a/packages/control-plane/src/routes/session-runtime-proxy.ts b/packages/control-plane/src/routes/session-runtime-proxy.ts
index c6838a29a..7eb9244a7 100644
--- a/packages/control-plane/src/routes/session-runtime-proxy.ts
+++ b/packages/control-plane/src/routes/session-runtime-proxy.ts
@@ -3,6 +3,10 @@ import type {
SessionParticipantProfilesResponse,
SessionParticipantProfile,
} from "@open-inspect/shared/types/sessions";
+import {
+ redactSessionSnapshotSandboxAccess,
+ sessionSnapshotSchema,
+} from "@open-inspect/shared/types/server-messages";
import { z } from "zod";
import { UserStore } from "../db/user-store";
import { SessionIndexStore } from "../db/session-index";
@@ -181,6 +185,29 @@ async function handleParticipantProfiles(
return Response.json({ profiles } satisfies SessionParticipantProfilesResponse);
}
+async function handleSessionSnapshot(
+ _request: Request,
+ _env: Env,
+ match: RegExpMatchArray,
+ ctx: SessionRouteContext
+): Promise {
+ const sessionId = getSessionId(match);
+ if (sessionId instanceof Response) return sessionId;
+
+ const response = await ctx.sessionRuntime.fetch(sessionId, SessionInternalPaths.snapshot);
+ if (response.status === 404) return error("Session not found", 404);
+ if (!response.ok) return response;
+
+ const parsed = sessionSnapshotSchema.safeParse(await response.json().catch(() => null));
+ if (!parsed.success) return error("Invalid session snapshot", 502);
+ const snapshot = ctx.authorization?.permissions.includes("sessions.sandbox_access")
+ ? parsed.data
+ : redactSessionSnapshotSandboxAccess(parsed.data);
+ const headers = new Headers(response.headers);
+ headers.delete("Content-Length");
+ return Response.json(snapshot, { headers });
+}
+
async function handleCreatePR(
request: Request,
_env: Env,
@@ -297,14 +324,15 @@ export const sessionRuntimeProxyRoutes: Route[] = [
internalPath: SessionInternalPaths.sandboxAccess,
authorization: requirePermission("sessions.sandbox_access"),
}),
- simpleProxyRoute({
- policy: SCM_AGNOSTIC_HUMAN_USER_ROUTE,
- method: "GET",
- routePath: "/sessions/:id",
- internalPath: SessionInternalPaths.snapshot,
- authorization: requirePermission("sessions.read"),
- notFoundMessage: "Session not found",
- }),
+ defineRoute(
+ SCM_AGNOSTIC_HUMAN_USER_ROUTE,
+ sessionRoute({
+ method: "GET",
+ pattern: parsePattern("/sessions/:id"),
+ authorization: requirePermission("sessions.read"),
+ handler: handleSessionSnapshot,
+ })
+ ),
simpleProxyRoute({
policy: GITHUB_USER_OR_SERVICE_ROUTE,
method: "POST",
diff --git a/packages/control-plane/src/routes/session-ws-token.test.ts b/packages/control-plane/src/routes/session-ws-token.test.ts
index 608d5f471..ec925177d 100644
--- a/packages/control-plane/src/routes/session-ws-token.test.ts
+++ b/packages/control-plane/src/routes/session-ws-token.test.ts
@@ -37,7 +37,7 @@ function createContext(db: SqlDatabase = accessDatabase().db): RequestContext {
userId: "user-1",
suspendedAt: null,
role: { id: "role-1", key: "member", name: "Member" },
- permissions: ["sessions.collaborate"],
+ permissions: ["sessions.read"],
},
metrics: {
d1Queries: [],
diff --git a/packages/control-plane/src/routes/session-ws-token.ts b/packages/control-plane/src/routes/session-ws-token.ts
index 096c2a775..8e959cde6 100644
--- a/packages/control-plane/src/routes/session-ws-token.ts
+++ b/packages/control-plane/src/routes/session-ws-token.ts
@@ -1,5 +1,5 @@
import { applyIdentityEnforcement } from "../auth/identity-enforcement";
-import { SESSION_WEBSOCKET_PERMISSIONS } from "@open-inspect/shared/rbac";
+import { SESSION_WEBSOCKET_CONNECT_PERMISSION } from "@open-inspect/shared/rbac";
import { SessionInternalPaths, sessionScmDisplayFieldsSchema } from "../session/contracts";
import type { Env } from "../types";
import {
@@ -8,8 +8,7 @@ import {
GITHUB_USER_OR_SERVICE_ROUTE,
parseJsonBody,
parsePattern,
- permissionRequirement,
- requireAll,
+ requirePermission,
type Route,
} from "./shared";
import { sessionRoute, type SessionRouteContext } from "./session-route";
@@ -60,7 +59,7 @@ export const sessionWsTokenRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE
sessionRoute({
method: "POST",
pattern: parsePattern("/sessions/:id/ws-token"),
- authorization: requireAll(...SESSION_WEBSOCKET_PERMISSIONS.map(permissionRequirement)),
+ authorization: requirePermission(SESSION_WEBSOCKET_CONNECT_PERMISSION),
handler: handleSessionWsToken,
}),
]);
diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts
index e4cb2453f..b5b2c5752 100644
--- a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts
+++ b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts
@@ -477,16 +477,14 @@ async function expectEarlyBridgeStartup(kind: ProviderStartupKind): Promise (message as { type: string }).type === "sandbox_access_changed"
)
- ).toHaveLength(1);
- expect(accessAtBroadcast).toEqual([
- {
- code_server_url: access.codeServerUrl,
- code_server_password: access.codeServerPassword,
- vnc_url: access.vncAccess.url,
- vnc_password: access.vncAccess.password,
- tunnel_urls: JSON.stringify(access.tunnelUrls),
- },
- ]);
+ ).not.toHaveLength(0);
+ expect(accessAtBroadcast.at(-1)).toEqual({
+ code_server_url: access.codeServerUrl,
+ code_server_password: access.codeServerPassword,
+ vnc_url: access.vncAccess.url,
+ vnc_password: access.vncAccess.password,
+ tunnel_urls: JSON.stringify(access.tunnelUrls),
+ });
}
// ==================== Tests ====================
@@ -844,13 +842,10 @@ describe("SandboxLifecycleManager", () => {
expect(storage.calls).toContain("updateSandboxModalObjectId:provider-obj-123");
expect(
- broadcaster.messages.filter((m) => (m as { type: string }).type === "sandbox_dashboard_url")
- ).toEqual([
- {
- type: "sandbox_dashboard_url",
- url: "https://provider.example/provider-obj-123",
- },
- ]);
+ broadcaster.messages.filter(
+ (m) => (m as { type: string }).type === "sandbox_access_changed"
+ )
+ ).toContainEqual({ type: "sandbox_access_changed" });
});
it("does not broadcast sandbox_dashboard_url when no builder is configured", async () => {
@@ -873,7 +868,7 @@ describe("SandboxLifecycleManager", () => {
expect(storage.calls).toContain("updateSandboxModalObjectId:provider-obj-123");
expect(
- broadcaster.messages.some((m) => (m as { type: string }).type === "sandbox_dashboard_url")
+ broadcaster.messages.some((m) => (m as { type: string }).type === "sandbox_access_changed")
).toBe(false);
});
@@ -1360,13 +1355,10 @@ describe("SandboxLifecycleManager", () => {
expect(storage.calls).toContain("updateSandboxModalObjectId:restored-obj-456");
expect(
- broadcaster.messages.filter((m) => (m as { type: string }).type === "sandbox_dashboard_url")
- ).toEqual([
- {
- type: "sandbox_dashboard_url",
- url: "https://provider.example/restored-obj-456",
- },
- ]);
+ broadcaster.messages.filter(
+ (m) => (m as { type: string }).type === "sandbox_access_changed"
+ )
+ ).toContainEqual({ type: "sandbox_access_changed" });
});
it("broadcasts sandbox_dashboard_url after resume when provider object id changes", async () => {
@@ -1405,13 +1397,10 @@ describe("SandboxLifecycleManager", () => {
expect(provider.resumeSandbox).toHaveBeenCalled();
expect(storage.calls).toContain("updateSandboxModalObjectId:new-provider-obj");
expect(
- broadcaster.messages.filter((m) => (m as { type: string }).type === "sandbox_dashboard_url")
- ).toEqual([
- {
- type: "sandbox_dashboard_url",
- url: "https://provider.example/new-provider-obj",
- },
- ]);
+ broadcaster.messages.filter(
+ (m) => (m as { type: string }).type === "sandbox_access_changed"
+ )
+ ).toContainEqual({ type: "sandbox_access_changed" });
});
it("broadcasts sandbox_dashboard_url after resume when provider object id is unchanged", async () => {
@@ -1450,13 +1439,10 @@ describe("SandboxLifecycleManager", () => {
expect(provider.resumeSandbox).toHaveBeenCalled();
expect(storage.calls).not.toContain("updateSandboxModalObjectId:same-provider-obj");
expect(
- broadcaster.messages.filter((m) => (m as { type: string }).type === "sandbox_dashboard_url")
- ).toEqual([
- {
- type: "sandbox_dashboard_url",
- url: "https://provider.example/same-provider-obj",
- },
- ]);
+ broadcaster.messages.filter(
+ (m) => (m as { type: string }).type === "sandbox_access_changed"
+ )
+ ).toContainEqual({ type: "sandbox_access_changed" });
});
it("does not carry a predecessor's runtime version onto a replacement's snapshot", async () => {
@@ -3540,11 +3526,7 @@ describe("SandboxLifecycleManager", () => {
expect(storage.calls).toContain("updateSandboxTunnelUrls");
expect(
- broadcaster.messages.some(
- (m) =>
- (m as { type: string }).type === "tunnel_urls" &&
- (m as { urls: Record }).urls["3000"] === "https://tunnel.example.com"
- )
+ broadcaster.messages.some((m) => (m as { type: string }).type === "sandbox_access_changed")
).toBe(true);
});
@@ -3644,11 +3626,7 @@ describe("SandboxLifecycleManager", () => {
expect(storage.calls).toContain("updateSandboxTunnelUrls");
expect(
- broadcaster.messages.some(
- (m) =>
- (m as { type: string }).type === "tunnel_urls" &&
- (m as { urls: Record }).urls["3000"] === "https://tunnel.example.com"
- )
+ broadcaster.messages.some((m) => (m as { type: string }).type === "sandbox_access_changed")
).toBe(true);
});
});
diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.ts b/packages/control-plane/src/sandbox/lifecycle/manager.ts
index b549de9e2..a2aabdce6 100644
--- a/packages/control-plane/src/sandbox/lifecycle/manager.ts
+++ b/packages/control-plane/src/sandbox/lifecycle/manager.ts
@@ -1597,7 +1597,7 @@ export class SandboxLifecycleManager implements SandboxLifecycle {
this.log.debug("Broadcasting sandbox dashboard URL", {
provider_object_id: providerObjectId,
});
- this.broadcaster.broadcast({ type: "sandbox_dashboard_url", url });
+ this.broadcaster.broadcast({ type: "sandbox_access_changed" });
}
}
@@ -1640,7 +1640,7 @@ export class SandboxLifecycleManager implements SandboxLifecycle {
if (!urls || Object.keys(urls).length === 0) return;
this.log.info("Storing and broadcasting tunnel URLs", { ports: Object.keys(urls) });
await this.storage.updateSandboxTunnelUrls(urls);
- this.broadcaster.broadcast({ type: "tunnel_urls", urls });
+ this.broadcaster.broadcast({ type: "sandbox_access_changed" });
}
/** Mint and persist terminal access. */
diff --git a/packages/control-plane/src/session/client-command-facade.ts b/packages/control-plane/src/session/client-command-facade.ts
index f7b7f4d99..f03aa879b 100644
--- a/packages/control-plane/src/session/client-command-facade.ts
+++ b/packages/control-plane/src/session/client-command-facade.ts
@@ -20,6 +20,7 @@ import type { SessionEventStream, SessionHistoryPage } from "./event-stream";
import type { SessionConnectionAuthenticator } from "./connection-authenticator";
import type { SessionMessageQueue } from "./message-queue";
import type { PresenceService } from "./presence-service";
+import type { PermissionId } from "@open-inspect/shared/rbac";
export class SessionClientCommandFacade implements SessionClientCommands {
constructor(
@@ -59,4 +60,11 @@ export class SessionClientCommandFacade implements SessionClientCommands {
+ return this.authenticator.authorizeClientCommand(client.userId, permission);
+ }
}
diff --git a/packages/control-plane/src/session/components.ts b/packages/control-plane/src/session/components.ts
index f1f80b69e..85858afde 100644
--- a/packages/control-plane/src/session/components.ts
+++ b/packages/control-plane/src/session/components.ts
@@ -22,7 +22,6 @@
*/
import { resolveAppName } from "@open-inspect/shared/app-name";
-import { SESSION_WEBSOCKET_PERMISSIONS } from "@open-inspect/shared/rbac";
import { DEFAULT_MODEL } from "@open-inspect/shared/models";
import { generateId, hashToken, encryptToken } from "../auth/crypto";
import { resolveSandboxBackendName } from "../sandbox/provider-name";
@@ -671,6 +670,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi
sessionCoreRepository,
sandboxRepository,
repoSecretsEncryptionKey,
+ sandboxDashboardSettings,
log,
});
@@ -687,23 +687,20 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi
snapshotReader,
schedulePullRequestRefresh,
scmProviderName,
- verifyAuthorization: async (userId) => {
- if (!db) return "unavailable";
+ resolveAuthorization: async (userId) => {
+ if (!db) return { kind: "unavailable" };
try {
const authorization = await new AuthorizationService(db).getEffectiveAuthorization(userId);
- return authorization.suspendedAt === null &&
- SESSION_WEBSOCKET_PERMISSIONS.every((permission) =>
- authorization.permissions.includes(permission)
- )
- ? "valid"
- : "rejected";
+ return authorization.suspendedAt === null
+ ? { kind: "valid", authorization }
+ : { kind: "rejected" };
} catch (error) {
- if (error instanceof AuthorizationError) return "rejected";
+ if (error instanceof AuthorizationError) return { kind: "rejected" };
log.error("WebSocket authorization verification failed", {
user_id: userId,
error: error instanceof Error ? error : String(error),
});
- return "unavailable";
+ return { kind: "unavailable" };
}
},
log,
diff --git a/packages/control-plane/src/session/connection-authenticator.ts b/packages/control-plane/src/session/connection-authenticator.ts
index 016c1c29c..80640ee0b 100644
--- a/packages/control-plane/src/session/connection-authenticator.ts
+++ b/packages/control-plane/src/session/connection-authenticator.ts
@@ -1,5 +1,9 @@
import { isSessionPromptable } from "@open-inspect/shared/types/session-activity";
-import type { ServerMessage } from "@open-inspect/shared/types/server-messages";
+import type { EffectiveAuthorization, PermissionId } from "@open-inspect/shared/rbac";
+import {
+ redactSessionSnapshotSandboxAccess,
+ type ServerMessage,
+} from "@open-inspect/shared/types/server-messages";
import {
WS_AUTHORIZATION_REVOKED_REASON,
WS_CLOSE_AUTHORIZATION_REVOKED,
@@ -45,12 +49,18 @@ export interface SessionConnectionAuthenticatorDeps {
snapshotReader: SessionSnapshotReader;
schedulePullRequestRefresh: (trigger: "open" | "manual") => void;
scmProviderName: SourceControlProviderName;
- /** Revalidate a user's session-collaboration permission before granting a lease. */
- verifyAuthorization: (userId: string) => Promise<"valid" | "rejected" | "unavailable">;
+ /** Resolve a user's current authorization at the start of a subscription or command. */
+ resolveAuthorization: (userId: string) => Promise;
/** The session-scoped logger; upgrade/subscribe paths also receive request-scoped children. */
log: Logger;
}
+type AuthorizationResolution =
+ | { kind: "valid"; authorization: EffectiveAuthorization }
+ | { kind: "rejected" | "unavailable" };
+
+export type ClientCommandAuthorization = "allowed" | "denied" | "unavailable";
+
/**
* Admits connections to the session: sandbox WebSocket upgrades (token +
* lifecycle-state guards, re-checked after the non-storage token-hash await),
@@ -270,18 +280,23 @@ export class SessionConnectionAuthenticator {
// Authorization is intentionally sampled once at the start of this
// subscription request. A concurrent role change takes effect when this
// bounded lease expires, not midway through an in-flight request.
- const authorization = await this.deps.verifyAuthorization(participant.canonical_user_id);
- if (authorization !== "valid") {
+ const authorization = await this.deps.resolveAuthorization(participant.canonical_user_id);
+ if (
+ authorization.kind !== "valid" ||
+ !authorization.authorization.permissions.includes("sessions.read")
+ ) {
log.warn("ws.connect", {
event: "ws.connect",
ws_type: "client",
outcome: "auth_failed",
reject_reason:
- authorization === "unavailable" ? "authorization_unavailable" : "authorization_denied",
+ authorization.kind === "unavailable"
+ ? "authorization_unavailable"
+ : "authorization_denied",
participant_id: participant.id,
user_id: participant.canonical_user_id,
});
- if (authorization === "unavailable") {
+ if (authorization.kind === "unavailable") {
wsManager.close(ws, WS_CLOSE_INTERNAL_ERROR, "Authorization temporarily unavailable");
} else {
wsManager.close(ws, WS_CLOSE_AUTHORIZATION_REVOKED, WS_AUTHORIZATION_REVOKED_REASON);
@@ -322,7 +337,12 @@ export class SessionConnectionAuthenticator {
try {
const activated = await wsManager.activateClient(ws, clientInfo, () =>
- this.completeClientSubscription(ws, clientInfo, enrichment)
+ this.completeClientSubscription(
+ ws,
+ clientInfo,
+ enrichment,
+ authorization.authorization.permissions.includes("sessions.sandbox_access")
+ )
);
if (!activated) {
wsManager.close(ws, 4009, "Session synchronization failed");
@@ -361,16 +381,20 @@ export class SessionConnectionAuthenticator {
private completeClientSubscription(
ws: WebSocket,
client: ClientInfo,
- enrichment: Parameters[0]
+ enrichment: Parameters[0],
+ canAccessSandbox: boolean
): boolean {
const { wsManager, snapshotReader } = this.deps;
const snapshot = snapshotReader.readSessionSnapshot(enrichment);
if (!snapshot) return false;
+ const authorizedSnapshot = canAccessSandbox
+ ? snapshot
+ : redactSessionSnapshotSandboxAccess(snapshot);
if (
!wsManager.send(ws, {
type: "subscribed",
- ...snapshot,
+ ...authorizedSnapshot,
participantId: client.participantId,
participant: {
participantId: client.participantId,
@@ -386,6 +410,18 @@ export class SessionConnectionAuthenticator {
return true;
}
+ /** Samples one permission before dispatching a WebSocket command. */
+ async authorizeClientCommand(
+ userId: string,
+ permission: PermissionId
+ ): Promise {
+ const resolution = await this.deps.resolveAuthorization(userId);
+ if (resolution.kind === "unavailable") return "unavailable";
+ if (resolution.kind === "rejected") return "denied";
+ if (resolution.kind !== "valid") return "denied";
+ return resolution.authorization.permissions.includes(permission) ? "allowed" : "denied";
+ }
+
/** Return authorized client state, recovering an unexpired lease after hibernation. */
getClientInfo(ws: WebSocket): ClientInfo | null {
const { wsManager, log } = this.deps;
diff --git a/packages/control-plane/src/session/message-router.ts b/packages/control-plane/src/session/message-router.ts
index 8cab30ee8..d3118a04b 100644
--- a/packages/control-plane/src/session/message-router.ts
+++ b/packages/control-plane/src/session/message-router.ts
@@ -1,6 +1,7 @@
import { sandboxEventSchema, type SandboxEvent } from "@open-inspect/shared/types/sandbox-events";
import { clientRequestIdSchema } from "@open-inspect/shared/types/prompts";
import { clientMessageSchema, type ClientMessage } from "@open-inspect/shared/types/websocket";
+import type { PermissionId } from "@open-inspect/shared/rbac";
import type { Logger } from "../logger";
import type { SessionHistoryPage } from "./event-stream";
import type { Clock, ConnectedClient, SocketRegistry } from "./ports";
@@ -33,6 +34,10 @@ export interface SessionClientCommands;
limit?: number;
}) => SessionHistoryPage;
+ authorize: (
+ client: Client,
+ permission: PermissionId
+ ) => Promise<"allowed" | "denied" | "unavailable">;
}
export interface SessionMessageRouterDeps {
@@ -104,15 +109,19 @@ export class SessionMessageRouter {
switch (data.type) {
case "prompt":
+ if (!(await this.authorizeCommand(connection, client, "sessions.collaborate"))) break;
await this.deps.clientCommands.submitPrompt(connection, client, data);
break;
case "cancel_prompt":
+ if (!(await this.authorizeCommand(connection, client, "sessions.lifecycle"))) break;
await this.deps.clientCommands.cancelPrompt(connection, data);
break;
case "stop":
+ if (!(await this.authorizeCommand(connection, client, "sessions.lifecycle"))) break;
await this.deps.clientCommands.stopExecution();
break;
case "typing":
+ if (!(await this.authorizeCommand(connection, client, "sessions.collaborate"))) break;
await this.deps.clientCommands.notifyTyping();
break;
case "fetch_history":
@@ -137,6 +146,24 @@ export class SessionMessageRouter {
}
}
+ private async authorizeCommand(
+ connection: Connection,
+ client: Client,
+ permission: PermissionId
+ ): Promise {
+ const result = await this.deps.clientCommands.authorize(client, permission);
+ if (result === "allowed") return true;
+ this.deps.sockets.send(connection, {
+ type: "error",
+ code: result === "unavailable" ? "AUTHORIZATION_UNAVAILABLE" : "PERMISSION_REQUIRED",
+ message:
+ result === "unavailable"
+ ? "Authorization is temporarily unavailable"
+ : `Permission required: ${permission}`,
+ });
+ return false;
+ }
+
private handleFetchHistory(connection: Connection, client: Client, data: FetchHistory): void {
if (
!data.cursor ||
diff --git a/packages/control-plane/src/session/sandbox-access-reader.ts b/packages/control-plane/src/session/sandbox-access-reader.ts
index 714454061..87804cb28 100644
--- a/packages/control-plane/src/session/sandbox-access-reader.ts
+++ b/packages/control-plane/src/session/sandbox-access-reader.ts
@@ -2,11 +2,14 @@ import type { Logger } from "../logger";
import { decryptStoredAccessValue } from "./sandbox-access";
import type { SandboxRepository } from "./sandbox-repository";
import type { SessionCoreRepository } from "./session-core-repository";
+import { resolveSandboxDashboardUrl, type SandboxDashboardSettings } from "./sandbox-access";
+import { safeParseTunnelUrls } from "./tunnel-urls";
export interface SessionAccessReaderDeps {
sessionCoreRepository: SessionCoreRepository;
sandboxRepository: SandboxRepository;
repoSecretsEncryptionKey: string;
+ sandboxDashboardSettings: SandboxDashboardSettings;
log: Logger;
}
@@ -44,7 +47,9 @@ export class SessionAccessReader {
current.vnc_url !== sandbox.vnc_url ||
current.vnc_password !== sandbox.vnc_password ||
current.ttyd_url !== sandbox.ttyd_url ||
- current.ttyd_token !== sandbox.ttyd_token
+ current.ttyd_token !== sandbox.ttyd_token ||
+ current.tunnel_urls !== sandbox.tunnel_urls ||
+ current.modal_object_id !== sandbox.modal_object_id
) {
return Response.json({ error: "Sandbox access changed; retry" }, { status: 409, headers });
}
@@ -57,6 +62,13 @@ export class SessionAccessReader {
vnc:
current.vnc_url && vncPassword ? { url: current.vnc_url, password: vncPassword } : null,
ttyd: current.ttyd_url && ttydToken ? { url: current.ttyd_url, token: ttydToken } : null,
+ tunnelUrls: current.tunnel_urls
+ ? safeParseTunnelUrls(current.tunnel_urls, this.deps.log)
+ : null,
+ sandboxDashboardUrl: resolveSandboxDashboardUrl(
+ this.deps.sandboxDashboardSettings,
+ current.modal_object_id
+ ),
},
{ headers }
);
diff --git a/packages/control-plane/src/session/server.test.ts b/packages/control-plane/src/session/server.test.ts
index 738de431d..70e807db1 100644
--- a/packages/control-plane/src/session/server.test.ts
+++ b/packages/control-plane/src/session/server.test.ts
@@ -56,6 +56,7 @@ function createHarness() {
notifyTyping: vi.fn(async () => undefined),
updatePresence: vi.fn(),
getHistoryPage: vi.fn(() => ({ items: [], hasMore: false, cursor: null })),
+ authorize: vi.fn(async () => "allowed" as const),
};
const sandbox: SandboxDisconnectMonitor = {
getStatus: vi.fn((): "ready" => "ready"),
@@ -251,6 +252,30 @@ describe("SessionServer", () => {
expect(clientCommands.stopExecution).not.toHaveBeenCalled();
});
+ it.each([
+ [{ type: "prompt", content: "work", clientRequestId: "request-1" }, "sessions.collaborate"],
+ [
+ { type: "cancel_prompt", messageId: "message-1", clientRequestId: "request-1" },
+ "sessions.lifecycle",
+ ],
+ [{ type: "stop" }, "sessions.lifecycle"],
+ ] as const)("rejects %s without its command permission", async (message, permission) => {
+ const { server, sockets, clientCommands, client } = createHarness();
+ vi.mocked(clientCommands.authorize).mockResolvedValue("denied");
+
+ await server.onMessage("client", JSON.stringify(message));
+
+ expect(clientCommands.authorize).toHaveBeenCalledWith(client, permission);
+ expect(sockets.send).toHaveBeenCalledWith("client", {
+ type: "error",
+ code: "PERMISSION_REQUIRED",
+ message: `Permission required: ${permission}`,
+ });
+ expect(clientCommands.submitPrompt).not.toHaveBeenCalled();
+ expect(clientCommands.cancelPrompt).not.toHaveBeenCalled();
+ expect(clientCommands.stopExecution).not.toHaveBeenCalled();
+ });
+
it("routes fetch_history and enforces throttling with the injected clock", async () => {
const { server, sockets, clientCommands, setNow } = createHarness();
const cursor = { timestamp: 10, id: "event-1", sequence: 2 };
diff --git a/packages/control-plane/test/integration/session-snapshot.test.ts b/packages/control-plane/test/integration/session-snapshot.test.ts
index b1d0dd897..0885129cb 100644
--- a/packages/control-plane/test/integration/session-snapshot.test.ts
+++ b/packages/control-plane/test/integration/session-snapshot.test.ts
@@ -73,6 +73,8 @@ describe("session snapshot synchronization", () => {
codeServer: { url: "https://code.example.test", password: "code-secret" },
vnc: { url: "https://desktop.example.test", password: "vnc-secret" },
ttyd: { url: "https://terminal.example.test", token: "terminal-secret" },
+ tunnelUrls: null,
+ sandboxDashboardUrl: null,
});
const { ws, messages } = await openClientWs(name, { subscribe: true });
diff --git a/packages/control-plane/test/integration/websocket-client.test.ts b/packages/control-plane/test/integration/websocket-client.test.ts
index bb69566ff..01576b5ec 100644
--- a/packages/control-plane/test/integration/websocket-client.test.ts
+++ b/packages/control-plane/test/integration/websocket-client.test.ts
@@ -243,7 +243,7 @@ describe("Client WebSocket (via SELF.fetch)", () => {
ws.close();
});
- it("rejects a custom role that cannot use the complete WebSocket protocol", async () => {
+ it("rejects a custom role that cannot read the session stream", async () => {
const suffix = Date.now();
const name = `ws-client-partial-role-${suffix}`;
const userId = `partial-role-user-${suffix}`;
@@ -291,7 +291,7 @@ describe("Client WebSocket (via SELF.fetch)", () => {
await expect(closed).resolves.toEqual({ code: 4010 });
});
- it("rejects a reconnect after collaborate permission is lost", async () => {
+ it("keeps the read stream after collaborate permission is lost but rejects prompts", async () => {
const name = `ws-client-lost-permission-${Date.now()}`;
const userId = `lost-permission-user-${Date.now()}`;
await initNamedSession(name);
@@ -303,12 +303,34 @@ describe("Client WebSocket (via SELF.fetch)", () => {
.run();
const { ws } = await openClientWs(name);
- const closed = new Promise<{ code: number }>((resolve) => {
- ws.addEventListener("close", (event) => resolve({ code: event.code }));
+ const subscribed = collectMessages(ws, {
+ until: (message) => message.type === "subscribed",
});
ws.send(JSON.stringify({ type: "subscribe", token, clientId: "lost-permission-client" }));
+ const snapshot = (await subscribed).find((message) => message.type === "subscribed") as Record<
+ string,
+ unknown
+ >;
- await expect(closed).resolves.toEqual({ code: 4010 });
+ expect(snapshot).toBeDefined();
+ expect(snapshot.session).not.toHaveProperty("sandboxDashboardUrl");
+
+ const denied = collectMessages(ws, {
+ until: (message) => message.type === "error",
+ });
+ ws.send(
+ JSON.stringify({
+ type: "prompt",
+ clientRequestId: crypto.randomUUID(),
+ content: "not allowed",
+ })
+ );
+
+ expect((await denied).find((message) => message.type === "error")).toMatchObject({
+ code: "PERMISSION_REQUIRED",
+ message: "Permission required: sessions.collaborate",
+ });
+ ws.close();
});
it("rejects a token after its canonical user is removed", async () => {
diff --git a/packages/control-plane/test/integration/websocket-sandbox.test.ts b/packages/control-plane/test/integration/websocket-sandbox.test.ts
index f22080f60..625c4e034 100644
--- a/packages/control-plane/test/integration/websocket-sandbox.test.ts
+++ b/packages/control-plane/test/integration/websocket-sandbox.test.ts
@@ -343,6 +343,8 @@ describe("Sandbox WebSocket (via SELF.fetch)", () => {
codeServer: { url: "https://code.test", password: "code-secret" },
vnc: { url: "https://vnc.test", password: "vnc-secret" },
ttyd: { url: "https://terminal.test", token: "terminal-token" },
+ tunnelUrls: null,
+ sandboxDashboardUrl: null,
});
sandboxWs!.close();
diff --git a/packages/shared/src/rbac.test.ts b/packages/shared/src/rbac.test.ts
index 352098523..bb2430ae3 100644
--- a/packages/shared/src/rbac.test.ts
+++ b/packages/shared/src/rbac.test.ts
@@ -5,6 +5,7 @@ import {
PERMISSION_IDS,
SCOPED_PERMISSION_PAIRS,
effectiveAuthorizationSchema,
+ hasScopedPermission,
permissionsForBuiltInRole,
resolveScopedPermission,
replaceMemberRoleInputSchema,
@@ -81,6 +82,11 @@ describe("RBAC registry", () => {
).toBe("any");
expect(resolveScopedPermission("automations.manage", ["automations.manage.own"])).toBe("own");
expect(resolveScopedPermission("automations.manage", [])).toBeNull();
+ expect(hasScopedPermission("automations.manage", ["automations.manage.any"], false)).toBe(true);
+ expect(hasScopedPermission("automations.manage", ["automations.manage.own"], true)).toBe(true);
+ expect(hasScopedPermission("automations.manage", ["automations.manage.own"], false)).toBe(
+ false
+ );
});
it("assigns every permission explicitly to Owner", () => {
diff --git a/packages/shared/src/rbac.ts b/packages/shared/src/rbac.ts
index 078f9d59f..6df70184a 100644
--- a/packages/shared/src/rbac.ts
+++ b/packages/shared/src/rbac.ts
@@ -77,12 +77,8 @@ export const PERMISSION_IDS = [
/** A permission identifier recognized by the RBAC policy. */
export type PermissionId = (typeof PERMISSION_IDS)[number];
-/** Permissions required to admit a browser WebSocket to the full session protocol. */
-export const SESSION_WEBSOCKET_PERMISSIONS = [
- "sessions.read",
- "sessions.collaborate",
- "sessions.lifecycle",
-] as const satisfies readonly PermissionId[];
+/** Permission required to admit a browser WebSocket to the read synchronization protocol. */
+export const SESSION_WEBSOCKET_CONNECT_PERMISSION = "sessions.read" as const satisfies PermissionId;
/** Maps ownership-sensitive capabilities to their workspace-wide and owner-only grants. */
export const SCOPED_PERMISSION_PAIRS = {
@@ -112,6 +108,16 @@ export function resolveScopedPermission(
return null;
}
+/** Decides a scoped resource capability from grants plus the caller's ownership result. */
+export function hasScopedPermission(
+ stem: ScopedPermissionStem,
+ permissions: readonly PermissionId[],
+ isOwner: boolean
+): boolean {
+ const scope = resolveScopedPermission(stem, permissions);
+ return scope === "any" || (scope === "own" && isOwner);
+}
+
const VIEWER_PERMISSIONS = new Set([
"analytics.read",
"automations.read",
diff --git a/packages/shared/src/types/server-messages.test.ts b/packages/shared/src/types/server-messages.test.ts
index 247d7c13e..61f426c97 100644
--- a/packages/shared/src/types/server-messages.test.ts
+++ b/packages/shared/src/types/server-messages.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
-import { serverMessageSchema, sessionSnapshotSchema } from "./server-messages";
+import {
+ redactSessionSnapshotSandboxAccess,
+ serverMessageSchema,
+ sessionSnapshotSchema,
+} from "./server-messages";
describe("artifact_updated server message", () => {
const artifact = {
@@ -113,6 +117,31 @@ describe("session view contracts", () => {
expect(parsed.timeline.events.map((item) => item.eventId)).toEqual(["event-1"]);
});
+ it("redacts sandbox locations without mutating the source snapshot", () => {
+ const snapshot = sessionSnapshotSchema.parse({
+ session: {
+ ...snapshotState,
+ codeServerUrl: "https://code.example",
+ vncUrl: "https://vnc.example",
+ ttydUrl: "https://terminal.example",
+ tunnelUrls: { "3000": "https://app.example" },
+ sandboxDashboardUrl: "https://provider.example",
+ },
+ artifacts: [],
+ promptQueue: [],
+ timeline: { events: [], hasMore: false, cursor: null },
+ });
+
+ const redacted = redactSessionSnapshotSandboxAccess(snapshot);
+
+ expect(redacted.session).not.toHaveProperty("codeServerUrl");
+ expect(redacted.session).not.toHaveProperty("vncUrl");
+ expect(redacted.session).not.toHaveProperty("ttydUrl");
+ expect(redacted.session).not.toHaveProperty("tunnelUrls");
+ expect(redacted.session).not.toHaveProperty("sandboxDashboardUrl");
+ expect(snapshot.session.codeServerUrl).toBe("https://code.example");
+ });
+
it("rejects malformed stable event envelopes", () => {
const snapshot = {
session: snapshotState,
diff --git a/packages/shared/src/types/server-messages.ts b/packages/shared/src/types/server-messages.ts
index 316c2b5ec..432c62356 100644
--- a/packages/shared/src/types/server-messages.ts
+++ b/packages/shared/src/types/server-messages.ts
@@ -118,6 +118,17 @@ export const sessionSnapshotSchema = z.object({
});
export type SessionSnapshot = z.infer;
+/** Removes sandbox location data before a snapshot crosses a read-only boundary. */
+export function redactSessionSnapshotSandboxAccess(snapshot: SessionSnapshot): SessionSnapshot {
+ const session = { ...snapshot.session };
+ delete session.codeServerUrl;
+ delete session.vncUrl;
+ delete session.ttydUrl;
+ delete session.tunnelUrls;
+ delete session.sandboxDashboardUrl;
+ return { ...snapshot, session };
+}
+
const serverMessageUnionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("pong"), timestamp: z.number() }),
sessionSnapshotSchema.extend({
diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx
index 9edf40da8..91192a80a 100644
--- a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx
+++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx
@@ -64,6 +64,7 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useSessionSnapshot } from "./session-snapshot-provider";
import { useSessionRename } from "@/hooks/use-session-rename";
import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization";
+import { resolveSessionCapabilities } from "@/lib/session-capabilities";
type SessionState = ReturnType["sessionState"];
@@ -73,9 +74,7 @@ const DEFAULT_SESSION_STATUS = "created" as const;
export default function SessionPage() {
const { shortcuts } = useKeyboardShortcuts();
const { hasPermission } = useCurrentUserAuthorization();
- const canCollaborate = hasPermission("sessions.collaborate");
- const canManageLifecycle = hasPermission("sessions.lifecycle");
- const canAccessSandbox = hasPermission("sessions.sandbox_access");
+ const capabilities = useMemo(() => resolveSessionCapabilities(hasPermission), [hasPermission]);
const initialSnapshot = useSessionSnapshot();
const sessionId = initialSnapshot.session.id;
const {
@@ -100,10 +99,7 @@ export default function SessionPage() {
sendTyping,
reconnect,
loadOlderEvents,
- } = useSessionSocket(sessionId, initialSnapshot, {
- collaborate: canCollaborate,
- sandboxAccess: canAccessSandbox,
- });
+ } = useSessionSocket(sessionId, initialSnapshot, capabilities);
const { profiles, participants: profiledParticipants } = useSessionParticipantProfiles(
sessionId,
participants,
@@ -151,13 +147,14 @@ export default function SessionPage() {
reasoningEffort,
loadingEnabledModels,
sessionState?.status ?? DEFAULT_SESSION_STATUS,
- ready && canCollaborate,
+ ready && capabilities.collaborate,
shortcuts["send-prompt"]
);
const [cancellingPromptIds, setCancellingPromptIds] = useState>(new Set());
const cancellingPromptIdsRef = useRef(new Set());
const handleRemoveQueuedPrompt = useCallback(
async (messageId: string) => {
+ if (!capabilities.lifecycle) return;
if (cancellingPromptIdsRef.current.has(messageId)) return;
const queuedPrompt = promptQueue.find((item) => item.messageId === messageId);
if (!queuedPrompt || queuedPrompt.status !== "pending") return;
@@ -183,7 +180,7 @@ export default function SessionPage() {
setCancellingPromptIds(new Set(cancellingPromptIdsRef.current));
}
},
- [cancelPrompt, promptQueue, restorePrompt, setSubmitError]
+ [cancelPrompt, capabilities.lifecycle, promptQueue, restorePrompt, setSubmitError]
);
const [selectedMediaArtifactId, setSelectedMediaArtifactId] = useState(null);
@@ -225,7 +222,13 @@ export default function SessionPage() {
}, [applyTerminalOpen]);
const ttydUrl = sessionState?.ttydUrl;
const ttydToken = sessionState?.ttydToken;
- const showTerminal = !!(canAccessSandbox && ttydUrl && ttydToken && terminalOpen && !isBelowLg);
+ const showTerminal = !!(
+ capabilities.sandboxAccess &&
+ ttydUrl &&
+ ttydToken &&
+ terminalOpen &&
+ !isBelowLg
+ );
const toggleDetails = useCallback(() => {
setIsDetailsOpen((prev) => !prev);
@@ -363,9 +366,9 @@ export default function SessionPage() {
promptQueue={promptQueue}
cancellingPromptIds={cancellingPromptIds}
onRemove={handleRemoveQueuedPrompt}
- canRemove={canCollaborate}
+ capabilities={capabilities}
/>
- {canCollaborate && (
+ {capabilities.collaborate && (
{/* Connection error banner */}
- {canCollaborate && (authError || connectionError) && (
+ {capabilities.read && (authError || connectionError) && (