diff --git a/coderd/authorize.go b/coderd/authorize.go index 10d6c519a7..1ea4cf7ff7 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -220,7 +220,7 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) { Type: string(v.Object.ResourceType), AnyOrgOwner: v.Object.AnyOrgOwner, } - if obj.Owner == "me" { + if obj.Owner == codersdk.Me { obj.Owner = auth.ID } diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 9d2d6e89a9..b68878e3cd 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -406,7 +406,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { agentsAccessRole := Role{ Identifier: RoleAgentsAccess(), - DisplayName: "Use Coder Agents", + DisplayName: "Coder Agents User", Site: []Permission{}, User: Permissions(map[string][]policy.Action{ ResourceChat.Type: { diff --git a/docs/ai-coder/agents/early-access.md b/docs/ai-coder/agents/early-access.md index afee5fce77..8a0fa419bb 100644 --- a/docs/ai-coder/agents/early-access.md +++ b/docs/ai-coder/agents/early-access.md @@ -65,9 +65,9 @@ Once the server restarts with the experiment enabled: 1. Navigate to the **Agents** page in the Coder dashboard. 1. Open **Admin** settings and configure at least one LLM provider and model. See [Models](./models.md) for detailed setup instructions. -1. Grant the **Use Coder Agents** role to users who need to create chats. +1. Grant the **Coder Agents User** role to users who need to create chats. Go to **Admin** > **Users**, click the roles icon next to each user, - and enable **Use Coder Agents**. + and enable **Coder Agents User**. 1. Developers can then start a new chat from the Agents page. ## Licensing and availability diff --git a/docs/ai-coder/agents/getting-started.md b/docs/ai-coder/agents/getting-started.md index 0989d2b7f8..3b74d75661 100644 --- a/docs/ai-coder/agents/getting-started.md +++ b/docs/ai-coder/agents/getting-started.md @@ -24,9 +24,9 @@ Before you begin, confirm the following: for the agent to select when provisioning workspaces. - **Admin access** to the Coder deployment for enabling the experiment and configuring providers. -- **Use Coder Agents role** assigned to each user who needs to create or use chats. +- **Coder Agents User role** assigned to each user who needs to create or use chats. Owners can assign this from **Admin** > **Users**. See - [Grant Use Coder Agents](#step-3-grant-use-coder-agents) below. + [Grant Coder Agents User](#step-3-grant-coder-agents-user) below. ## Step 1: Enable the experiment @@ -72,14 +72,14 @@ Detailed instructions for each provider and model option are in the > Start with a single frontier model to validate your setup before adding > additional providers. -## Step 3: Grant Use Coder Agents +## Step 3: Grant Coder Agents User -The **Use Coder Agents** role controls which users can create and use chats. -Members do not have Use Coder Agents by default. +The **Coder Agents User** role controls which users can create and use chats. +Members do not have Coder Agents User by default. 1. Go to **Admin** > **Users** in the Coder dashboard. 1. Click the roles icon next to the user you want to grant access to. -1. Enable the **Use Coder Agents** role and save. +1. Enable the **Coder Agents User** role and save. Repeat for each user who needs access. Owners always have full access and do not need the role. diff --git a/site/permissions.json b/site/permissions.json index b346d5167b..09cb75d0fb 100644 --- a/site/permissions.json +++ b/site/permissions.json @@ -118,5 +118,9 @@ "viewOAuth2AppSecrets": { "object": { "resource_type": "oauth2_app_secret" }, "action": "read" + }, + "createChat": { + "object": { "resource_type": "chat", "owner_id": "me" }, + "action": "create" } } diff --git a/site/site.go b/site/site.go index 4497f558f9..edbd8f2a81 100644 --- a/site/site.go +++ b/site/site.go @@ -571,9 +571,16 @@ func init() { func (h *Handler) renderPermissions(ctx context.Context, actor rbac.Subject) string { response := make(codersdk.AuthorizationResponse) for k, v := range permissionChecks { + // Resolve the "me" sentinel so permission checks + // run against the actual actor, matching the + // API-side handling in coderd/authorize.go. + ownerID := v.Object.OwnerID + if ownerID == codersdk.Me { + ownerID = actor.ID + } obj := rbac.Object{ ID: v.Object.ResourceID, - Owner: v.Object.OwnerID, + Owner: ownerID, OrgID: v.Object.OrganizationID, AnyOrgOwner: v.Object.AnyOrgOwner, Type: string(v.Object.ResourceType), diff --git a/site/site_test.go b/site/site_test.go index 3527f31106..90855250b2 100644 --- a/site/site_test.go +++ b/site/site_test.go @@ -21,6 +21,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/exp/maps" @@ -31,6 +32,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/site" @@ -79,6 +81,74 @@ func TestInjection(t *testing.T) { require.Equal(t, db2sdk.User(user, []uuid.UUID{}), got) } +func TestRenderPermissionsResolvesMe(t *testing.T) { + t.Parallel() + + // GIVEN: a site handler wired to a real RBAC authorizer and a + // template that renders only the SSR permissions JSON. + siteFS := fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte("{{ .Permissions }}"), + }, + } + db, _ := dbtestutil.NewDB(t) + authorizer := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()) + + handler, err := site.New(&site.Options{ + Telemetry: telemetry.NewNoop(), + Database: db, + SiteFS: siteFS, + Authorizer: authorizer, + }) + require.NoError(t, err) + + // GIVEN: a user with the agents-access role. + userWithRole := dbgen.User(t, db, database.User{ + RBACRoles: []string{"agents-access"}, + }) + _, tokenWithRole := dbgen.APIKey(t, db, database.APIKey{ + UserID: userWithRole.ID, + ExpiresAt: time.Now().Add(time.Hour), + }) + + // WHEN: the user loads the page. + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set(codersdk.SessionTokenHeader, tokenWithRole) + rw := httptest.NewRecorder() + handler.ServeHTTP(rw, r) + require.Equal(t, http.StatusOK, rw.Code) + + // THEN: the SSR-rendered permissions include createChat = true + // because the "me" sentinel in permissions.json was resolved to + // the actor's ID, and the agents-access role grants user-scoped + // chat create permission. + var permsWithRole codersdk.AuthorizationResponse + err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &permsWithRole) + require.NoError(t, err) + assert.True(t, permsWithRole["createChat"], "user with agents-access role should have createChat = true") + + // GIVEN: a user without the agents-access role. + userWithoutRole := dbgen.User(t, db, database.User{}) + _, tokenWithoutRole := dbgen.APIKey(t, db, database.APIKey{ + UserID: userWithoutRole.ID, + ExpiresAt: time.Now().Add(time.Hour), + }) + + // WHEN: the user loads the page. + r = httptest.NewRequest("GET", "/", nil) + r.Header.Set(codersdk.SessionTokenHeader, tokenWithoutRole) + rw = httptest.NewRecorder() + handler.ServeHTTP(rw, r) + require.Equal(t, http.StatusOK, rw.Code) + + // THEN: createChat = false because the member role does not + // grant chat permissions. + var permsWithoutRole codersdk.AuthorizationResponse + err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &permsWithoutRole) + require.NoError(t, err) + assert.False(t, permsWithoutRole["createChat"], "user without agents-access role should have createChat = false") +} + func TestInjectionFailureProducesCleanHTML(t *testing.T) { t.Parallel() diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 64670a56e5..5fa2ad57e8 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -9,6 +9,7 @@ import { } from "#/api/queries/chats"; import { workspaces } from "#/api/queries/workspaces"; import type * as TypesGen from "#/api/typesGenerated"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { AgentCreateForm, type CreateChatOptions, @@ -24,6 +25,7 @@ const nilUUID = "00000000-0000-0000-0000-000000000000"; const AgentCreatePage: FC = () => { const queryClient = useQueryClient(); const navigate = useNavigate(); + const { permissions } = useAuthenticated(); const chatModelsQuery = useQuery(chatModels()); const chatModelConfigsQuery = useQuery(chatModelConfigs()); @@ -79,6 +81,7 @@ const AgentCreatePage: FC = () => { onCreateChat={handleCreateChat} isCreating={createMutation.isPending} createError={createMutation.error} + canCreateChat={permissions.createChat} modelCatalog={chatModelsQuery.data} modelOptions={catalogModelOptions} modelConfigs={chatModelConfigsQuery.data ?? []} diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 67b01d445b..986026e956 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -15,6 +15,25 @@ const modelOptions = [ }, ] as const; +const mock403Error = Object.assign( + new Error("Request failed with status code 403"), + { + isAxiosError: true, + response: { + status: 403, + statusText: "Forbidden", + data: { + message: "Forbidden.", + detail: "Insufficient permissions to create chat.", + }, + headers: {}, + config: {}, + }, + config: {}, + toJSON: () => ({}), + }, +); + const meta: Meta = { title: "pages/AgentsPage/AgentCreateForm", component: AgentCreateForm, @@ -23,6 +42,7 @@ const meta: Meta = { onCreateChat: fn(), isCreating: false, createError: undefined, + canCreateChat: true, modelCatalog: null, modelOptions: [...modelOptions], isModelCatalogLoading: false, @@ -268,3 +288,46 @@ export const UsageLimitExceeded: Story = { ), }, }; + +export const ForbiddenErrorWithRole: Story = { + args: { + ...defaultArgs, + canCreateChat: true, + createError: mock403Error, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // The friendly "role required" alert must NOT appear because the + // user has the agents-access role. + await expect( + canvas.queryByText("Permission required"), + ).not.toBeInTheDocument(); + // The generic ErrorAlert should surface the real backend message. + await expect(canvas.getByText("Forbidden.")).toBeInTheDocument(); + // The textbox should remain enabled since the user has the role. + const textbox = canvas.getByRole("textbox"); + await expect(textbox).not.toHaveAttribute("aria-disabled", "true"); + }, +}; + +export const ForbiddenNoAgentsRole: Story = { + args: { + ...defaultArgs, + canCreateChat: false, + createError: mock403Error, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Permission required")).toBeInTheDocument(); + await expect( + canvas.getByRole("link", { name: /View Docs/ }), + ).toBeInTheDocument(); + await expect( + canvas.queryByRole("heading", { name: "Forbidden." }), + ).not.toBeInTheDocument(); + // The textarea should be disabled so the user cannot + // accidentally trigger the generic error. + const textbox = canvas.getByRole("textbox"); + await expect(textbox).toHaveAttribute("aria-disabled", "true"); + }, +}; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index f8ab79cc74..89cbc90776 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -18,6 +18,7 @@ import { isUsageLimitData, } from "../utils/usageLimitMessage"; import { AgentChatInput } from "./AgentChatInput"; +import { ChatAccessDeniedAlert } from "./ChatAccessDeniedAlert"; import type { ModelSelectorOption } from "./ChatElements"; import { getDefaultMCPSelection, @@ -95,6 +96,7 @@ interface AgentCreateFormProps { onCreateChat: (options: CreateChatOptions) => Promise; isCreating: boolean; createError: unknown; + canCreateChat: boolean; modelCatalog: TypesGen.ChatModelsResponse | null | undefined; modelOptions: readonly ChatModelOption[]; isModelCatalogLoading: boolean; @@ -112,6 +114,7 @@ export const AgentCreateForm: FC = ({ onCreateChat, isCreating, createError, + canCreateChat, modelCatalog, modelOptions, modelConfigs, @@ -284,10 +287,14 @@ export const AgentCreateForm: FC = ({ } }; + const isForbidden = !canCreateChat; + return (
- {createError ? ( + {isForbidden ? ( + + ) : createError ? ( isApiError(createError) && createError.response?.status === 409 && isUsageLimitData(createError.response.data) ? ( @@ -310,7 +317,7 @@ export const AgentCreateForm: FC = ({ { + const docsLink = docs( + "/ai-coder/agents/getting-started#step-3-grant-coder-agents-user", + ); + + return ( + + + + View Docs + +
+ } + > +

Permission required

+

+ You don't have permission to create chats. Contact your Coder + administrator for access. Refresh this page after access has been + granted. +

+ + ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.tsx b/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.tsx index 3ff32adcbd..636322a7d9 100644 --- a/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.tsx +++ b/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.tsx @@ -29,7 +29,7 @@ const roleDescriptions: Record = { "user-admin": "User admin can manage all users and groups.", "template-admin": "Template admin can manage all templates and workspaces.", auditor: "Auditor can access the audit logs.", - "agents-access": "Use Coder Agents allows creating and using AI chats.", + "agents-access": "Coder Agents User allows creating and using AI chats.", member: "Everybody is a member. This is a shared and default role for all users.", }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index d185d19c34..d1102c7dec 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3119,6 +3119,7 @@ export const MockPermissions: Permissions = { editOAuth2App: true, deleteOAuth2App: true, viewOAuth2AppSecrets: true, + createChat: true, }; export const MockNoPermissions: Permissions = { @@ -3152,6 +3153,7 @@ export const MockNoPermissions: Permissions = { editOAuth2App: false, deleteOAuth2App: false, viewOAuth2AppSecrets: false, + createChat: false, }; export const MockOrganizationPermissions: OrganizationPermissions = {