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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -118,5 +118,9 @@
|
||||
"viewOAuth2AppSecrets": {
|
||||
"object": { "resource_type": "oauth2_app_secret" },
|
||||
"action": "read"
|
||||
},
|
||||
"createChat": {
|
||||
"object": { "resource_type": "chat", "owner_id": "me" },
|
||||
"action": "create"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 ?? []}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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.",
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user