string-array |
-Replaces the roles every member of the organization holds. Accepts a comma-separated list and may be repeated. An empty value removes every role. New organizations start with organization-workspace-access, which grants members access to their own workspaces.
+Replaces the roles every member of the organization holds. Accepts a comma-separated list and may be repeated. An empty value removes every role. New organizations start with organization-workspace-access, which grants members access to their own workspaces, and agents-access, which grants members access to Coder Agents.
### -y, --yes
diff --git a/enterprise/coderd/exp_chats_test.go b/enterprise/coderd/exp_chats_test.go
index 2aeacb9767..f805e8ec15 100644
--- a/enterprise/coderd/exp_chats_test.go
+++ b/enterprise/coderd/exp_chats_test.go
@@ -1153,6 +1153,60 @@ func TestCreateChatUsesOrganizationLocalModel(t *testing.T) {
require.True(t, found, "chat should be visible in list")
}
+func TestCreateChatAgentsAccessDefaultRole(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, firstUser := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureMultipleOrganizations: 1,
+ },
+ },
+ })
+ expClient := codersdk.NewExperimentalClient(client)
+ provider := createOpenAIProviderForTest(ctx, t, expClient, "test-key", "https://example.com")
+ _, err := expClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
+ AIProviderID: &provider.ID,
+ Model: "gpt-4o-mini",
+ IsDefault: new(true),
+ ContextLimit: new(int64(1000)),
+ })
+ require.NoError(t, err)
+
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+ createChat := func() error {
+ _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
+ OrganizationID: firstUser.OrganizationID,
+ Content: []codersdk.ChatInputPart{
+ {Type: codersdk.ChatInputPartTypeText, Text: "hello"},
+ },
+ })
+ return err
+ }
+ setDefaults := func(roles []string) {
+ t.Helper()
+ //nolint:gocritic // Only owners can update organization settings.
+ updated, err := client.UpdateOrganization(ctx, firstUser.OrganizationID.String(), codersdk.UpdateOrganizationRequest{
+ DefaultOrgMemberRoles: &roles,
+ })
+ require.NoError(t, err)
+ require.Equal(t, roles, updated.DefaultOrgMemberRoles)
+ }
+
+ require.NoError(t, createChat())
+
+ setDefaults([]string{codersdk.RoleOrganizationWorkspaceAccess})
+ err = createChat()
+ var sdkErr *codersdk.Error
+ require.ErrorAs(t, err, &sdkErr)
+ require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
+
+ setDefaults(rbac.DefaultOrgMemberRoles())
+ require.NoError(t, createChat())
+}
+
// TestCreateChatCrossOrgModelConfigRejected proves an explicit
// model_config_id naming a config in a DIFFERENT org than the chat is
// rejected as unavailable: post-cutover validation is org-aware, so a
diff --git a/enterprise/coderd/organizations.go b/enterprise/coderd/organizations.go
index 0b1eacd292..2e40635eb8 100644
--- a/enterprise/coderd/organizations.go
+++ b/enterprise/coderd/organizations.go
@@ -67,7 +67,20 @@ func (api *API) patchOrganization(rw http.ResponseWriter, r *http.Request) {
// for every member of the org. A future change can extend this to
// custom org roles by routing through canAssignRoles in dbauthz.
if req.DefaultOrgMemberRoles != nil {
+ seen := make(map[string]struct{}, len(*req.DefaultOrgMemberRoles))
for _, name := range *req.DefaultOrgMemberRoles {
+ if _, ok := seen[name]; ok {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Invalid default_org_member_roles entry.",
+ Detail: fmt.Sprintf("%q is listed more than once.", name),
+ Validations: []codersdk.ValidationError{{
+ Field: "default_org_member_roles",
+ Detail: fmt.Sprintf("%q is listed more than once.", name),
+ }},
+ })
+ return
+ }
+ seen[name] = struct{}{}
if _, err := rbac.RoleByName(rbac.RoleIdentifier{Name: name, OrganizationID: organization.ID}); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid default_org_member_roles entry.",
diff --git a/enterprise/coderd/organizations_test.go b/enterprise/coderd/organizations_test.go
index 7ea2da96c3..24f94c2746 100644
--- a/enterprise/coderd/organizations_test.go
+++ b/enterprise/coderd/organizations_test.go
@@ -519,6 +519,37 @@ func TestPatchOrganizationsByUser(t *testing.T) {
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Invalid default_org_member_roles entry")
})
+
+ t.Run("DuplicateRoleRejected", func(t *testing.T) {
+ t.Parallel()
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureMultipleOrganizations: 1,
+ },
+ },
+ })
+ ctx := testutil.Context(t, testutil.WaitMedium)
+ o := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
+
+ //nolint:gocritic // Only owners can update organization settings.
+ _, err := client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{
+ DefaultOrgMemberRoles: new([]string{
+ codersdk.RoleOrganizationWorkspaceAccess,
+ codersdk.RoleAgentsAccess,
+ codersdk.RoleAgentsAccess,
+ }),
+ })
+ var apiErr *codersdk.Error
+ require.ErrorAs(t, err, &apiErr)
+ require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
+ require.Contains(t, apiErr.Detail, "listed more than once")
+
+ //nolint:gocritic // The owner verifies the stored defaults are unchanged.
+ got, err := client.Organization(ctx, o.ID)
+ require.NoError(t, err)
+ require.Equal(t, rbac.DefaultOrgMemberRoles(), got.DefaultOrgMemberRoles)
+ })
})
}
diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go
index c4ea6607bf..d33b4dd489 100644
--- a/enterprise/coderd/roles.go
+++ b/enterprise/coderd/roles.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
+ "strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
@@ -205,10 +206,8 @@ func (api *API) deleteOrgRole(rw http.ResponseWriter, r *http.Request) {
rolename := chi.URLParam(r, "roleName")
- // Catch requests that try to delete system roles. Retired built-in
- // names skip this: they stay reserved for creation and updates, but a
- // custom role that predates the reservation must remain deletable.
- if !rbac.IsRetiredRoleName(rolename) && !validOrganizationRoleRequest(ctx, codersdk.CustomRoleRequest{Name: rolename}, rw) {
+ // Catch requests that try to delete system roles.
+ if !validOrganizationRoleRequest(ctx, codersdk.CustomRoleRequest{Name: rolename}, rw) {
return
}
@@ -293,8 +292,8 @@ func sdkPermissionToDB(p codersdk.Permission) database.CustomRolePermission {
func validOrganizationRoleRequest(ctx context.Context, req codersdk.CustomRoleRequest, rw http.ResponseWriter) bool {
// This check is not ideal, but we cannot enforce a unique role name in the db against
- // the built-in role names.
- if rbac.ReservedRoleName(req.Name) {
+ // the built-in role names. Custom role names are stored lowercase.
+ if rbac.ReservedRoleName(strings.ToLower(req.Name)) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Reserved role name",
Detail: fmt.Sprintf("%q is a reserved role name, and not allowed to be used", req.Name),
diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go
index 1def3e0846..b2c44658ef 100644
--- a/enterprise/coderd/roles_test.go
+++ b/enterprise/coderd/roles_test.go
@@ -11,10 +11,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
- "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
- "github.com/coder/coder/v2/coderd/database/dbgen"
- "github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
@@ -220,6 +217,24 @@ func TestCustomOrganizationRole(t *testing.T) {
UserPermissions: nil,
})
require.ErrorContains(t, err, "Reserved")
+
+ //nolint:gocritic // owner is required for this
+ _, err = owner.CreateOrganizationRole(ctx, codersdk.Role{
+ Name: codersdk.RoleAgentsAccess,
+ DisplayName: "Testing Purposes",
+ OrganizationID: first.OrganizationID.String(),
+ })
+ require.ErrorContains(t, err, "Reserved")
+
+ // Custom role names are stored lowercase, so a case variant would
+ // shadow the built-in role.
+ //nolint:gocritic // owner is required for this
+ _, err = owner.CreateOrganizationRole(ctx, codersdk.Role{
+ Name: "Agents-Access",
+ DisplayName: "Testing Purposes",
+ OrganizationID: first.OrganizationID.String(),
+ })
+ require.ErrorContains(t, err, "Reserved")
})
// Attempt to add site & user permissions, which is not allowed
@@ -333,36 +348,6 @@ func TestCustomOrganizationRole(t *testing.T) {
require.ErrorContains(t, err, "Resource not found")
})
- t.Run("DeleteRetiredName", func(t *testing.T) {
- t.Parallel()
- db, ps := dbtestutil.NewDB(t)
- owner, first := coderdenttest.New(t, &coderdenttest.Options{
- Options: &coderdtest.Options{Database: db, Pubsub: ps},
- LicenseOptions: &coderdenttest.LicenseOptions{
- Features: license.Features{
- codersdk.FeatureCustomRoles: 1,
- },
- },
- })
-
- orgAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID))
- ctx := testutil.Context(t, testutil.WaitMedium)
-
- // A custom role that took the name before it became a reserved
- // retired built-in name. It cannot be created or updated through
- // the API anymore, but it must remain deletable.
- dbgen.CustomRole(t, db, database.CustomRole{
- Name: "agents-access",
- OrganizationID: uuid.NullUUID{
- UUID: first.OrganizationID,
- Valid: true,
- },
- })
-
- err := orgAdmin.DeleteOrganizationRole(ctx, first.OrganizationID, "agents-access")
- require.NoError(t, err)
- })
-
t.Run("Delete", func(t *testing.T) {
t.Parallel()
owner, first := coderdenttest.New(t, &coderdenttest.Options{
@@ -539,6 +524,7 @@ func TestListRoles(t *testing.T) {
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: false,
{Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: false,
{Name: codersdk.RoleOrganizationWorkspaceAccess, OrganizationID: owner.OrganizationID}: false,
+ {Name: codersdk.RoleAgentsAccess, OrganizationID: owner.OrganizationID}: false,
}),
},
{
@@ -573,6 +559,7 @@ func TestListRoles(t *testing.T) {
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true,
{Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: true,
{Name: codersdk.RoleOrganizationWorkspaceAccess, OrganizationID: owner.OrganizationID}: true,
+ {Name: codersdk.RoleAgentsAccess, OrganizationID: owner.OrganizationID}: true,
}),
},
{
@@ -607,6 +594,7 @@ func TestListRoles(t *testing.T) {
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true,
{Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: true,
{Name: codersdk.RoleOrganizationWorkspaceAccess, OrganizationID: owner.OrganizationID}: true,
+ {Name: codersdk.RoleAgentsAccess, OrganizationID: owner.OrganizationID}: true,
}),
},
}
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index b607cac34b..495e758e9a 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -8719,9 +8719,9 @@ export interface Role {
// From codersdk/rbacroles.go
/**
* Ideally these roles would be generated from the rbac/roles.go package.
- * @deprecated the agents-access role was removed. Coder Agents chat
- * access is part of the organization-member permission floor, and
- * servers without this built-in role reject assigning it.
+ * RoleAgentsAccess is the organization role that grants Coder Agents
+ * chat access. Organizations include it in their default member roles
+ * unless an administrator removes it.
*/
export const RoleAgentsAccess = "agents-access";
diff --git a/site/src/modules/roles/index.ts b/site/src/modules/roles/index.ts
index 55246db385..0f61146889 100644
--- a/site/src/modules/roles/index.ts
+++ b/site/src/modules/roles/index.ts
@@ -10,6 +10,7 @@ export const roleDescriptions: Record