From cd8613e8ba5abe4b1e08cba763e5f5bb54b6afaf Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:19:38 +0200 Subject: [PATCH] fix(site): include owner context in Agents org picker permission check (#28076) Fixes the Agents org picker and workspace attach menu for users whose only chat grant comes from the member-scoped "Coder Agents User" (`agents-access`) org role (PRODUCT-552). ## Problem The per-org authcheck behind the org picker checked `chat:create` with only `organization_id`. The `agents-access` role grants chat permissions at org-member scope, which requires the checked object to be owned by the caller (`policy.rego` `org_member` requires a non-empty owner matching the subject). With no `owner_id`, every org check returned `false`, so: - the org picker never rendered (`permittedOrgs.length > 1` gate), - the form stayed pinned to the default org, - the workspace attach menu, filtered to that org, showed "No workspaces found" even though the user had workspaces in another org. The page-level `createChat` check already includes `owner_id: "me"`, which is why the same user could load the page and create chats via the API. ## Fix Pass `owner_id: "me"` in the `permittedOrganizations` seed check in `AgentCreateForm`, matching the page-level check's semantics. The `permittedOrganizations` helper spreads the check object through, so each per-org check now carries owner context and the backend substitutes the caller's user ID. The other `permittedOrganizations` callsites (`organization_member:create`, `template:create`) check org-scoped admin permissions and correctly omit `owner_id`. Adds a regression story whose `checkAuthorization` mock only allows checks carrying `owner_id: "me"` (mirroring the RBAC member-scope behavior); it fails without the fix and passes with it (red-green verified). ## Validation - Red-green verified regression story: fails without the fix (picker not found), passes with it; all 31 stories in the file pass. - `pnpm -C site check` and `pnpm -C site lint:types` clean. - Remote dogfood UAT (dev.coder.com chat [97be7f39](https://dev.coder.com/agents/97be7f39-4688-45ee-be3d-24bc4f6f8046)): PASS on all acceptance criteria at this exact commit. Reproduced the bug scenario end to end (two orgs, non-admin user with only the "Coder Agents User" role in both, workspace only in the second org): the org picker renders, the second org's workspaces appear in the attach menu, and chat creation succeeds with a real model. Single-org and admin behaviors unchanged. Authcheck probe documents the backend semantics: `chat:create` with `owner_id: "me"` returns true, without it returns false. > Mux acted on Mike's behalf for this PR. (cherry picked from commit d509e1e6a03ff7d88996cb637acc0ef5c131e950) --- site/src/api/queries/organizations.ts | 8 ++- .../components/AgentCreateForm.stories.tsx | 57 +++++++++++++++++-- .../AgentsPage/components/AgentCreateForm.tsx | 2 +- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 1dcaac36596..6caf0d6f958 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -294,6 +294,12 @@ export const provisionerJobs = ( }; }; +export const permittedOrganizationsKey = (check: AuthorizationCheck) => [ + "organizations", + "permitted", + check, +]; + /** * Fetch organizations the current user is permitted to use for a given * action. Fetches all organizations, runs a per-org authorization @@ -301,7 +307,7 @@ export const provisionerJobs = ( */ export const permittedOrganizations = (check: AuthorizationCheck) => { return { - queryKey: ["organizations", "permitted", check], + queryKey: permittedOrganizationsKey(check), queryFn: async (): Promise => { const orgs = await API.getOrganizations(); const checks = Object.fromEntries( diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index e38c344494d..46fb4784803 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -9,6 +9,7 @@ import { within, } from "storybook/test"; import { API } from "#/api/api"; +import { permittedOrganizationsKey } from "#/api/queries/organizations"; import type * as TypesGen from "#/api/typesGenerated"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { MockChatModelConfig } from "#/testHelpers/chatModels"; @@ -24,12 +25,10 @@ import { } from "../utils/reasoningEffort"; import { AgentCreateForm } from "./AgentCreateForm"; -// Query key used by permittedOrganizations() in the form. -const permittedOrgsKey = [ - "organizations", - "permitted", - { object: { resource_type: "chat" }, action: "create" }, -]; +const permittedOrgsKey = permittedOrganizationsKey({ + object: { resource_type: "chat", owner_id: "me" }, + action: "create", +}); const modelConfigID = "model-config-1"; const claudeModelConfigID = "model-config-claude"; @@ -1061,3 +1060,49 @@ export const PermittedOrgsResolvesToSubset: Story = { expect(options.organizationId).toBe(MockOrganization2.id); }, }; + +/** + * Member-scoped roles like agents-access grant chat:create only on + * chats the user owns, so the per-org check must carry owner context + * for the picker to render. + */ +export const MemberScopedPermissionsShowOrgPicker: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + beforeEach: () => { + spyOn(API, "getOrganizations").mockResolvedValue([ + MockDefaultOrganization, + MockOrganization2, + ]); + spyOn(API, "checkAuthorization").mockImplementation(async ({ checks }) => + Object.fromEntries( + Object.entries(checks).map(([id, check]) => [ + id, + check.object.owner_id === "me", + ]), + ), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const picker = await canvas.findByRole( + "button", + { name: /^Organization:/ }, + { timeout: 3000 }, + ); + await userEvent.click(picker); + await screen.findByRole("option", { + name: MockDefaultOrganization.display_name, + }); + await userEvent.click( + screen.getByRole("option", { name: MockOrganization2.display_name }), + ); + expect( + canvas.getByRole("button", { + name: `Organization: ${MockOrganization2.display_name}`, + }), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 0a494c01dcf..22165f04b54 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -461,7 +461,7 @@ export const AgentCreateForm: FC = ({ const permittedOrgsQuery = useQuery({ ...permittedOrganizations({ - object: { resource_type: "chat" }, + object: { resource_type: "chat", owner_id: "me" }, action: "create", }), enabled: showOrganizations,