feat: show friendly alert for missing agents-access role (#23831)

Replaces the generic red `ErrorAlert` ("Forbidden.") with a proactive
permission check and friendly info alert when a user lacks the
`agents-access` role.

- Add `createChat` permission check to `permissions.json` using
`owner_id: "me"`
- Handle `"me"` owner substitution in `renderPermissions` (SSR path)
- Pass `canCreateChat` from `useAuthenticated().permissions` into
`AgentCreateForm`
- Show `ChatAccessDeniedAlert` and disable input immediately (no need to
trigger a 403 first)
- Also catch 403 errors as a fallback in case permissions aren't yet
loaded
- Add `ForbiddenNoAgentsRole` Storybook story with `play` assertions
- Add `TestRenderPermissionsResolvesMe` Go test to pin the `"me"`
sentinel substitution

<details><summary>Implementation plan & decision log</summary>

- Uses the existing `permissions.json` + `checkAuthorization` system
rather than a separate API call
- `owner_id: "me"` is resolved to the actor's ID by both the auth-check
API endpoint and the SSR `renderPermissions` function
- Go test uses a real `rbac.StrictCachingAuthorizer` (not a mock) so it
verifies both the sentinel substitution and the RBAC role evaluation
end-to-end
- Alert follows the exact same `Alert` pattern as the 409 usage-limit
block
- Uses `severity="info"` and links to the getting-started docs Step 3
- Textarea is disabled proactively so the user never sees the scary
generic error

</details>

> 🤖 Created by a Coder Agent and will be reviewed by a human.
This commit is contained in:
Cian Johnston
2026-03-31 17:26:58 +01:00
committed by GitHub
parent c86f1288f1
commit 2a990ce758
13 changed files with 209 additions and 14 deletions

View File

@@ -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
}

View File

@@ -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: {

View File

@@ -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

View File

@@ -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.

View File

@@ -118,5 +118,9 @@
"viewOAuth2AppSecrets": {
"object": { "resource_type": "oauth2_app_secret" },
"action": "read"
},
"createChat": {
"object": { "resource_type": "chat", "owner_id": "me" },
"action": "create"
}
}

View File

@@ -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),

View File

@@ -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()

View File

@@ -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 ?? []}

View File

@@ -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<typeof AgentCreateForm> = {
title: "pages/AgentsPage/AgentCreateForm",
component: AgentCreateForm,
@@ -23,6 +42,7 @@ const meta: Meta<typeof AgentCreateForm> = {
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");
},
};

View File

@@ -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<void>;
isCreating: boolean;
createError: unknown;
canCreateChat: boolean;
modelCatalog: TypesGen.ChatModelsResponse | null | undefined;
modelOptions: readonly ChatModelOption[];
isModelCatalogLoading: boolean;
@@ -112,6 +114,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
onCreateChat,
isCreating,
createError,
canCreateChat,
modelCatalog,
modelOptions,
modelConfigs,
@@ -284,10 +287,14 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
}
};
const isForbidden = !canCreateChat;
return (
<div className="flex min-h-0 flex-1 items-start justify-center overflow-auto p-4 pt-12 md:h-full md:items-center md:pt-4">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
{createError ? (
{isForbidden ? (
<ChatAccessDeniedAlert />
) : createError ? (
isApiError(createError) &&
createError.response?.status === 409 &&
isUsageLimitData(createError.response.data) ? (
@@ -310,7 +317,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
<AgentChatInput
onSend={handleSendWithAttachments}
placeholder="Ask Coder to build, fix bugs, or explore your project..."
isDisabled={isCreating}
isDisabled={isCreating || isForbidden}
isLoading={isCreating}
initialValue={initialInputValue}
onContentChange={handleContentChange}

View File

@@ -0,0 +1,39 @@
import type { FC } from "react";
import { Alert } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { Link } from "#/components/Link/Link";
import { docs } from "#/utils/docs";
export const ChatAccessDeniedAlert: FC = () => {
const docsLink = docs(
"/ai-coder/agents/getting-started#step-3-grant-coder-agents-user",
);
return (
<Alert
severity="info"
className="py-2"
actions={
<div className="flex gap-2">
<Button
variant="subtle"
size="sm"
onClick={() => window.location.reload()}
>
Refresh
</Button>
<Link href={docsLink} target="_blank" rel="noreferrer" size="sm">
View Docs
</Link>
</div>
}
>
<p className="m-0 font-medium">Permission required</p>
<p className="m-0 mt-1 text-sm text-content-secondary">
You don't have permission to create chats. Contact your Coder
administrator for access. Refresh this page after access has been
granted.
</p>
</Alert>
);
};

View File

@@ -29,7 +29,7 @@ const roleDescriptions: Record<string, string> = {
"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.",
};

View File

@@ -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 = {