mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat: restore agents-access as a default organization role (#30004)
Restores `agents-access` (**Coder Agents User**) as a built-in organization role and adds it to every organization's `default_org_member_roles`. Members keep Coder Agents by default, and admins can now restrict it per organization by removing the role from the defaults and granting it through IdP role sync or direct assignment. Since #28186, chat permissions lived in the organization-member floor, so the only way to limit Coder Agents was narrowing per-model access lists, which did not hide the feature. - Migration 000602 appends `agents-access` to every organization's defaults, so upgrading keeps every member's access. New organizations get it from `rbac.DefaultOrgMemberRoles()`. - Service accounts do not inherit the role from the defaults (`GetAuthorizationUserRoles`), so they still need an explicit grant for chat. - Owners and Organization Admins keep chat access regardless of defaults. Chats shared with a user stay readable without the role. - Role sync is unchanged. It does not store default roles, so after the default is removed a mapped user gets `agents-access` at their next sign-in. - The retired-role mechanism (`IsRetiredRoleName`) is removed. - `PATCH /api/v2/organizations/{organization}` rejects duplicate `default_org_member_roles` entries (the CLI already de-duplicated them). - Custom organization role requests treat built-in role names case-insensitively, since custom role names are stored lowercase and `Agents-Access` would otherwise become an undeletable shadow of the built-in role. - The Agents permission alert tells restricted members their chats are kept, and the model selector for users without chat permission no longer claims that no models are configured. Docs add "Control who can use Coder Agents" to the Agents getting-started guide. **NOTE**: Migration 000602 deletes custom roles named `agents-access`, which the built-in role would shadow. The down migration does not restore them. Stored member grants of `agents-access` from the earlier built-in role or a deleted custom role stay and now grant chat access. ## Validation - Remote dogfood [UAT](https://dogfood.cdr.dev/agents/e1c18dc6-f6f0-49a1-b6cd-6ce014b51a86) passed onfb441dfae5, covering case variants of built-in role names on create, update, delete, default roles, and member grants. The previous round, ona6fef66b3f, passed the upgrade from main5dde49dffbon a fresh database.6b7f107e0bonly changes wording (docs, comments, UI copy, CLI help) and did not go through UAT. - Local: rbac, dbauthz, idpsync, migrations (chain including 000602 down and up), querier, httpmw, provisionerdserver, coderd and enterprise chat, role, and organization tests, chatd, toolsdk, CLI; frontend vitest, story tests, biome, oxlint, tsc. ## Known issues (not changed here) - Sending from a chat page that was already open when access was removed shows a generic 404 panel until reload. - The members page role editor is not locked for OIDC users under org role sync; the API rejects the change with 400. - Creating a custom role named after a system role (`organization-member`, `organization-service-account`) returns 400 with a raw database unique-constraint error. <details> <summary>Review record</summary> - Remote UAT (UAT Critic sub-agent `6636c855ec`): round 1 on8bec029d35passed with issues, fixed ind7ba937a10and e0f16e1a45f; round 2 ona6fef66b3fpassed; round 3 onfb441dfae5passed. - Codex: round 1 ona6fef66b3ffound one P2 (case variants of built-in role names), fixed infb441dfae5with a red-green test. Rounds 2 and 3 onfb441dfae5found no major issues. - mafredri onfb441dfae5(PR text only): nine wording suggestions applied in 6b7f107e0b9; NOTE extended for stored grants; description trimmed. - Frontend: FE1 to FE10 and coder-frontend-review checkpoints self-audited. </details> > 🤖 This PR was opened by [Xum](https://mux.coder.com) on behalf of @ibetitsmike.
This commit is contained in:
@@ -70,7 +70,11 @@ func (r *RootCmd) editOrganization(orgContext *OrganizationContext) *serpent.Com
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Replace the roles every member of the organization holds",
|
||||
Command: "coder organizations edit --default-org-member-roles organization-workspace-access,organization-template-admin",
|
||||
Command: "coder organizations edit --default-org-member-roles organization-workspace-access,agents-access,organization-template-admin",
|
||||
},
|
||||
Example{
|
||||
Description: "Restrict Coder Agents to members assigned agents-access directly",
|
||||
Command: "coder organizations edit --default-org-member-roles organization-workspace-access",
|
||||
},
|
||||
Example{
|
||||
Description: "Grant members no roles at all",
|
||||
@@ -87,7 +91,8 @@ func (r *RootCmd) editOrganization(orgContext *OrganizationContext) *serpent.Com
|
||||
Description: "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.",
|
||||
"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.",
|
||||
Value: serpent.StringArrayOf(&defaultOrgMemberRoles),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
|
||||
+8
-2
@@ -8,7 +8,12 @@ USAGE:
|
||||
- Replace the roles every member of the organization holds:
|
||||
|
||||
$ coder organizations edit --default-org-member-roles
|
||||
organization-workspace-access,organization-template-admin
|
||||
organization-workspace-access,agents-access,organization-template-admin
|
||||
|
||||
- Restrict Coder Agents to members assigned agents-access directly:
|
||||
|
||||
$ coder organizations edit --default-org-member-roles
|
||||
organization-workspace-access
|
||||
|
||||
- Grant members no roles at all:
|
||||
|
||||
@@ -22,7 +27,8 @@ OPTIONS:
|
||||
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.
|
||||
which grants members access to their own workspaces, and
|
||||
agents-access, which grants members access to Coder Agents.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass confirmation prompts.
|
||||
|
||||
@@ -1416,15 +1416,6 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID uuid.UUID, added, re
|
||||
grantedRoles := make([]rbac.RoleIdentifier, 0, len(added)+len(removed))
|
||||
grantedRoles = append(grantedRoles, added...)
|
||||
grantedRoles = append(grantedRoles, removed...)
|
||||
// Retired role names may linger in stored role arrays and org default
|
||||
// role lists until a data cleanup migration lands. They expand to no
|
||||
// permissions and cannot be re-created as custom roles, so validating
|
||||
// them is unnecessary: the added set only contains them via stored
|
||||
// data (implied org defaults), never via explicit grants, which the
|
||||
// role-update paths reject before reaching this filter.
|
||||
grantedRoles = slices.DeleteFunc(grantedRoles, func(r rbac.RoleIdentifier) bool {
|
||||
return rbac.IsRetiredRoleName(r.Name)
|
||||
})
|
||||
customRoles := make([]rbac.RoleIdentifier, 0)
|
||||
// Validate that the roles being assigned are valid.
|
||||
for _, r := range grantedRoles {
|
||||
@@ -7923,17 +7914,6 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb
|
||||
if err != nil {
|
||||
return database.OrganizationMember{}, err
|
||||
}
|
||||
// Explicitly granting a retired role is rejected. Retired-name tolerance
|
||||
// only covers stored grants and implied org defaults that linger until
|
||||
// the cleanup migration lands; without this check the request would
|
||||
// skip validation and persist a hidden grant that a binary rollback
|
||||
// resolves again.
|
||||
for _, role := range scopedGranted {
|
||||
if rbac.IsRetiredRoleName(role.Name) {
|
||||
return database.OrganizationMember{}, xerrors.Errorf("role %q is retired and cannot be assigned", role.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// The org's default_org_member_roles are implied at request time by
|
||||
// GetAuthorizationUserRoles. Include them in the implied set so
|
||||
// canAssignRoles validates the caller can grant the full effective set
|
||||
@@ -8009,15 +7989,6 @@ func (q *querier) UpdateOrganization(ctx context.Context, arg database.UpdateOrg
|
||||
scopedOrgRoleIdentifiers(existing.DefaultOrgMemberRoles, arg.ID),
|
||||
scopedOrgRoleIdentifiers(arg.DefaultOrgMemberRoles, arg.ID),
|
||||
)
|
||||
// Newly added defaults must not include retired names, which
|
||||
// canAssignRoles tolerates only so stale stored defaults keep
|
||||
// working until the cleanup migration lands. Removals stay
|
||||
// tolerated so those stale defaults can be cleaned up.
|
||||
for _, role := range added {
|
||||
if rbac.IsRetiredRoleName(role.Name) {
|
||||
return database.Organization{}, xerrors.Errorf("role %q is retired and cannot be a default role", role.Name)
|
||||
}
|
||||
}
|
||||
if err := q.canAssignRoles(ctx, arg.ID, added, removed); err != nil {
|
||||
return database.Organization{}, err
|
||||
}
|
||||
@@ -8556,15 +8527,6 @@ func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRo
|
||||
return database.User{}, err
|
||||
}
|
||||
|
||||
// Explicitly granting a retired role is rejected. Retired-name tolerance
|
||||
// only covers stored grants that linger until the cleanup migration
|
||||
// lands.
|
||||
for _, roleName := range arg.GrantedRoles {
|
||||
if rbac.IsRetiredRoleName(roleName) {
|
||||
return database.User{}, xerrors.Errorf("role %q is retired and cannot be assigned", roleName)
|
||||
}
|
||||
}
|
||||
|
||||
// The member role is always implied.
|
||||
impliedTypes := append(q.convertToDeploymentRoles(arg.GrantedRoles), rbac.RoleMember())
|
||||
// If the changeset is nothing, less rbac checks need to be done.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Deleted legacy custom roles named agents-access are not restored.
|
||||
UPDATE organizations
|
||||
SET default_org_member_roles = array_remove(default_org_member_roles, 'agents-access')
|
||||
WHERE 'agents-access' = ANY(default_org_member_roles);
|
||||
|
||||
UPDATE organization_members
|
||||
SET roles = array_remove(roles, 'agents-access')
|
||||
WHERE 'agents-access' = ANY(roles);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Custom roles named agents-access could predate the built-in role. The
|
||||
-- built-in would shadow them and make them undeletable.
|
||||
DELETE FROM custom_roles WHERE name = 'agents-access';
|
||||
|
||||
UPDATE organizations
|
||||
SET default_org_member_roles = array_append(default_org_member_roles, 'agents-access')
|
||||
WHERE NOT ('agents-access' = ANY(default_org_member_roles));
|
||||
@@ -403,6 +403,7 @@ func TestMigrationChain(t *testing.T) {
|
||||
{"Migration000587RemoveAgentsAccessRole", 587, testMigration000587RemoveAgentsAccessRole},
|
||||
{"Migration000590WorkspaceAgentSessionCounts", 590, testMigration000590WorkspaceAgentSessionCounts},
|
||||
{"Migration000595RemoveTaskPermissions", 595, testMigration000595RemoveTaskPermissions},
|
||||
{"Migration000602RestoreAgentsAccessDefaultRole", 602, testMigration000602RestoreAgentsAccessDefaultRole},
|
||||
}
|
||||
for _, step := range steps {
|
||||
stepTo(step.version - 1)
|
||||
@@ -1662,6 +1663,105 @@ func testMigration000587RemoveAgentsAccessRole(t *testing.T, sqlDB *sql.DB, next
|
||||
require.Equal(t, []string{"organization-workspace-access"}, []string(defaultRoles))
|
||||
}
|
||||
|
||||
func testMigration000602RestoreAgentsAccessDefaultRole(t *testing.T, sqlDB *sql.DB, next migrationStepper) {
|
||||
const migrationVersion = 602
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db := database.New(sqlDB)
|
||||
|
||||
orgs := []struct {
|
||||
defaults []string
|
||||
want []string
|
||||
}{
|
||||
{[]string{"organization-workspace-access"}, []string{"organization-workspace-access", "agents-access"}},
|
||||
{[]string{}, []string{"agents-access"}},
|
||||
{[]string{"organization-workspace-access", "organization-auditor"}, []string{"organization-workspace-access", "organization-auditor", "agents-access"}},
|
||||
}
|
||||
orgIDs := make([]uuid.UUID, len(orgs))
|
||||
for i, o := range orgs {
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
// dbgen replaces an empty slice with the current defaults.
|
||||
_, err := sqlDB.ExecContext(ctx, "UPDATE organizations SET default_org_member_roles = $1 WHERE id = $2", pq.StringArray(o.defaults), org.ID)
|
||||
require.NoError(t, err)
|
||||
orgIDs[i] = org.ID
|
||||
}
|
||||
|
||||
member := dbgen.User(t, db, database.User{})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: orgIDs[0],
|
||||
UserID: member.ID,
|
||||
Roles: []string{"organization-auditor"},
|
||||
})
|
||||
serviceAccount := dbgen.User(t, db, database.User{IsServiceAccount: true})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: orgIDs[0],
|
||||
UserID: serviceAccount.ID,
|
||||
Roles: []string{},
|
||||
})
|
||||
legacyRole := dbgen.CustomRole(t, db, database.CustomRole{
|
||||
Name: "agents-access",
|
||||
OrganizationID: uuid.NullUUID{UUID: orgIDs[1], Valid: true},
|
||||
})
|
||||
unrelatedRole := dbgen.CustomRole(t, db, database.CustomRole{
|
||||
OrganizationID: uuid.NullUUID{UUID: orgIDs[1], Valid: true},
|
||||
})
|
||||
|
||||
memberRoles := func(userID uuid.UUID) []string {
|
||||
t.Helper()
|
||||
var roles pq.StringArray
|
||||
err := sqlDB.QueryRowContext(ctx, "SELECT roles FROM organization_members WHERE organization_id = $1 AND user_id = $2", orgIDs[0], userID).Scan(&roles)
|
||||
require.NoError(t, err)
|
||||
return roles
|
||||
}
|
||||
assertDefaults := func(withAgentsAccess bool) {
|
||||
t.Helper()
|
||||
for i, o := range orgs {
|
||||
var roles pq.StringArray
|
||||
err := sqlDB.QueryRowContext(ctx, "SELECT default_org_member_roles FROM organizations WHERE id = $1", orgIDs[i]).Scan(&roles)
|
||||
require.NoError(t, err)
|
||||
want := o.defaults
|
||||
if withAgentsAccess {
|
||||
want = o.want
|
||||
}
|
||||
require.Equal(t, want, []string(roles), "org %d", i)
|
||||
}
|
||||
}
|
||||
customRoleExists := func(id uuid.UUID) bool {
|
||||
t.Helper()
|
||||
var exists bool
|
||||
err := sqlDB.QueryRowContext(ctx, "SELECT EXISTS (SELECT 1 FROM custom_roles WHERE id = $1)", id).Scan(&exists)
|
||||
require.NoError(t, err)
|
||||
return exists
|
||||
}
|
||||
|
||||
version, _, err := next()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, migrationVersion, version)
|
||||
assertDefaults(true)
|
||||
require.Equal(t, []string{"organization-auditor"}, memberRoles(member.ID))
|
||||
require.Empty(t, memberRoles(serviceAccount.ID))
|
||||
require.False(t, customRoleExists(legacyRole.ID))
|
||||
require.True(t, customRoleExists(unrelatedRole.ID))
|
||||
|
||||
// An explicit grant made after the upgrade is removed on downgrade.
|
||||
_, err = sqlDB.ExecContext(ctx, "UPDATE organization_members SET roles = array_append(roles, 'agents-access') WHERE user_id = $1", serviceAccount.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
downSQL, err := os.ReadFile("000602_restore_agents_access_default_role.down.sql")
|
||||
require.NoError(t, err)
|
||||
_, err = sqlDB.ExecContext(ctx, string(downSQL))
|
||||
require.NoError(t, err)
|
||||
assertDefaults(false)
|
||||
require.Equal(t, []string{"organization-auditor"}, memberRoles(member.ID))
|
||||
require.Empty(t, memberRoles(serviceAccount.ID))
|
||||
|
||||
upSQL, err := os.ReadFile("000602_restore_agents_access_default_role.up.sql")
|
||||
require.NoError(t, err)
|
||||
_, err = sqlDB.ExecContext(ctx, string(upSQL))
|
||||
require.NoError(t, err)
|
||||
assertDefaults(true)
|
||||
}
|
||||
|
||||
func TestMigration000504AIProvidersBackfill(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -3758,7 +3758,7 @@ func TestGetAuthorizationUserRolesImpliedOrgRole(t *testing.T) {
|
||||
// every member's effective roles include the org's defaults, and changes
|
||||
// to the column propagate on the next request. The union applies to
|
||||
// regular users and to service accounts; the SQL array_cats the column
|
||||
// for both code paths.
|
||||
// for both code paths (minus agents-access for service accounts).
|
||||
func TestGetAuthorizationUserRolesUnionsDefaultOrgMemberRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -3809,6 +3809,44 @@ func TestGetAuthorizationUserRolesUnionsDefaultOrgMemberRoles(t *testing.T) {
|
||||
require.NotContains(t, shrunkSA.Roles, wantWorkspaceAccess)
|
||||
}
|
||||
|
||||
// TestGetAuthorizationUserRolesServiceAccountAgentsAccess verifies service
|
||||
// accounts do not inherit agents-access from the org defaults but keep an
|
||||
// explicit grant.
|
||||
func TestGetAuthorizationUserRolesServiceAccountAgentsAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
require.Contains(t, org.DefaultOrgMemberRoles, rbac.RoleAgentsAccess())
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
sa := dbgen.User(t, db, database.User{IsServiceAccount: true})
|
||||
grantedSA := dbgen.User(t, db, database.User{IsServiceAccount: true})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: org.ID, UserID: user.ID})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: org.ID, UserID: sa.ID})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: org.ID,
|
||||
UserID: grantedSA.ID,
|
||||
Roles: []string{rbac.RoleAgentsAccess()},
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
wantAgentsAccess := rbac.ScopedRoleAgentsAccess(org.ID).String()
|
||||
wantWorkspaceAccess := rbac.ScopedRoleOrgWorkspaceAccess(org.ID).String()
|
||||
|
||||
userRoles, err := db.GetAuthorizationUserRoles(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, userRoles.Roles, wantAgentsAccess)
|
||||
|
||||
saRoles, err := db.GetAuthorizationUserRoles(ctx, sa.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, saRoles.Roles, wantAgentsAccess)
|
||||
require.Contains(t, saRoles.Roles, wantWorkspaceAccess)
|
||||
|
||||
grantedRoles, err := db.GetAuthorizationUserRoles(ctx, grantedSA.ID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, grantedRoles.Roles, wantAgentsAccess)
|
||||
}
|
||||
|
||||
func TestUpdateOrganizationWorkspaceSharingSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Generated
+7
-1
@@ -31515,6 +31515,8 @@ SELECT
|
||||
--
|
||||
-- organizations.default_org_member_roles is unioned in so changes
|
||||
-- to org defaults propagate to every member on the next request.
|
||||
-- Service accounts do not inherit agents-access from the defaults
|
||||
-- so they only get chat access through an explicit grant.
|
||||
unnest(
|
||||
array_cat(
|
||||
array_append(
|
||||
@@ -31525,7 +31527,11 @@ SELECT
|
||||
'organization-member'
|
||||
END
|
||||
),
|
||||
organizations.default_org_member_roles
|
||||
CASE WHEN users.is_service_account THEN
|
||||
array_remove(organizations.default_org_member_roles, 'agents-access')
|
||||
ELSE
|
||||
organizations.default_org_member_roles
|
||||
END
|
||||
)
|
||||
) AS org_roles
|
||||
WHERE
|
||||
|
||||
@@ -620,6 +620,8 @@ SELECT
|
||||
--
|
||||
-- organizations.default_org_member_roles is unioned in so changes
|
||||
-- to org defaults propagate to every member on the next request.
|
||||
-- Service accounts do not inherit agents-access from the defaults
|
||||
-- so they only get chat access through an explicit grant.
|
||||
unnest(
|
||||
array_cat(
|
||||
array_append(
|
||||
@@ -630,7 +632,11 @@ SELECT
|
||||
'organization-member'
|
||||
END
|
||||
),
|
||||
organizations.default_org_member_roles
|
||||
CASE WHEN users.is_service_account THEN
|
||||
array_remove(organizations.default_org_member_roles, 'agents-access')
|
||||
ELSE
|
||||
organizations.default_org_member_roles
|
||||
END
|
||||
)
|
||||
) AS org_roles
|
||||
WHERE
|
||||
|
||||
@@ -1203,6 +1203,63 @@ func TestPostChats(t *testing.T) {
|
||||
require.Equal(t, member.ID, chat.OwnerID)
|
||||
})
|
||||
|
||||
t.Run("AgentsAccessDefaultRole", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
rawDB, pubsub := dbtestutil.NewDB(t)
|
||||
client := newChatClient(t, func(opts *coderdtest.Options) {
|
||||
opts.Database = rawDB
|
||||
opts.Pubsub = pubsub
|
||||
})
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
_ = createChatModel(t, client)
|
||||
|
||||
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.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()
|
||||
org, err := rawDB.GetOrganizationByID(ctx, firstUser.OrganizationID)
|
||||
require.NoError(t, err)
|
||||
_, err = rawDB.UpdateOrganization(ctx, database.UpdateOrganizationParams{
|
||||
ID: org.ID,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
Name: org.Name,
|
||||
DisplayName: org.DisplayName,
|
||||
Description: org.Description,
|
||||
Icon: org.Icon,
|
||||
DefaultOrgMemberRoles: roles,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
setMemberRoles := func(roles []string) {
|
||||
t.Helper()
|
||||
_, err := client.UpdateOrganizationMemberRoles(ctx, firstUser.OrganizationID, member.ID.String(), codersdk.UpdateRoles{Roles: roles})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
setDefaults([]string{codersdk.RoleOrganizationWorkspaceAccess})
|
||||
requireSDKError(t, createChat(), http.StatusForbidden)
|
||||
|
||||
setMemberRoles([]string{codersdk.RoleAgentsAccess})
|
||||
require.NoError(t, createChat())
|
||||
|
||||
setMemberRoles([]string{})
|
||||
requireSDKError(t, createChat(), http.StatusForbidden)
|
||||
|
||||
setDefaults(rbac.DefaultOrgMemberRoles())
|
||||
require.NoError(t, createChat())
|
||||
})
|
||||
|
||||
t.Run("WithReasoningEffort", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestExtractUserRoles(t *testing.T) {
|
||||
Roles: orgRoles,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return user, []rbac.RoleIdentifier{rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID), rbac.ScopedRoleOrgWorkspaceAccess(org.ID)}, token
|
||||
return user, []rbac.RoleIdentifier{rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID), rbac.ScopedRoleOrgWorkspaceAccess(org.ID), rbac.ScopedRoleAgentsAccess(org.ID)}, token
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -103,6 +103,7 @@ func TestExtractUserRoles(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
expected = append(expected, rbac.ScopedRoleOrgMember(organization.ID))
|
||||
expected = append(expected, rbac.ScopedRoleOrgWorkspaceAccess(organization.ID))
|
||||
expected = append(expected, rbac.ScopedRoleAgentsAccess(organization.ID))
|
||||
}
|
||||
return user, expected, token
|
||||
},
|
||||
|
||||
@@ -748,7 +748,8 @@ func SetupOrganization(t *testing.T, s *idpsync.AGPLIDPSync, db database.Store,
|
||||
org, err := db.GetOrganizationByID(context.Background(), orgID)
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
org = dbgen.Organization(t, db, database.Organization{
|
||||
ID: orgID,
|
||||
ID: orgID,
|
||||
DefaultOrgMemberRoles: def.DefaultOrgMemberRoles,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -835,6 +836,9 @@ type orgSetupDefinition struct {
|
||||
GroupNames map[string]bool
|
||||
OrganizationRoles []string
|
||||
CustomRoles []string
|
||||
// DefaultOrgMemberRoles overrides the organization's default member
|
||||
// roles when non-empty.
|
||||
DefaultOrgMemberRoles []string
|
||||
// NotMember if true will ensure the user is not a member of the organization.
|
||||
NotMember bool
|
||||
|
||||
|
||||
@@ -163,6 +163,42 @@ func TestRoleSyncTable(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// agents-access removed from the org defaults is granted
|
||||
// through role sync like any other role.
|
||||
Name: "AgentsAccessNotDefault",
|
||||
OrganizationRoles: []string{},
|
||||
DefaultOrgMemberRoles: []string{rbac.RoleOrgWorkspaceAccess()},
|
||||
RoleSettings: &idpsync.RoleSyncSettings{
|
||||
Field: "roles",
|
||||
Mapping: map[string][]string{
|
||||
"foo": {rbac.RoleAgentsAccess()},
|
||||
},
|
||||
},
|
||||
assertRoles: &orgRoleAssert{
|
||||
ExpectedOrgRoles: []string{
|
||||
rbac.RoleOrgAuditor(),
|
||||
rbac.RoleAgentsAccess(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// While agents-access is a default it is implicit, so role
|
||||
// sync neither stores nor strips it.
|
||||
Name: "AgentsAccessDefault",
|
||||
OrganizationRoles: []string{},
|
||||
RoleSettings: &idpsync.RoleSyncSettings{
|
||||
Field: "roles",
|
||||
Mapping: map[string][]string{
|
||||
"foo": {rbac.RoleAgentsAccess()},
|
||||
},
|
||||
},
|
||||
assertRoles: &orgRoleAssert{
|
||||
ExpectedOrgRoles: []string{
|
||||
rbac.RoleOrgAuditor(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "NonExistentClaim",
|
||||
OrganizationRoles: []string{rbac.RoleOrgAuditor()},
|
||||
|
||||
@@ -691,6 +691,11 @@ func TestAcquireJob(t *testing.T) {
|
||||
|
||||
<-startPublished
|
||||
|
||||
if wk, ok := job.Type.(*proto.AcquiredJob_WorkspaceBuild_); ok {
|
||||
slices.SortFunc(wk.WorkspaceBuild.Metadata.WorkspaceOwnerRbacRoles, func(a, b *sdkproto.Role) int {
|
||||
return strings.Compare(a.Name+a.OrgId, b.Name+b.OrgId)
|
||||
})
|
||||
}
|
||||
got, err := json.Marshal(job.Type)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -724,7 +729,7 @@ func TestAcquireJob(t *testing.T) {
|
||||
WorkspaceOwnerSshPrivateKey: sshKey.PrivateKey,
|
||||
WorkspaceBuildId: build.ID.String(),
|
||||
WorkspaceOwnerLoginType: string(user.LoginType),
|
||||
WorkspaceOwnerRbacRoles: []*sdkproto.Role{{Name: rbac.RoleOrgMember(), OrgId: pd.OrganizationID.String()}, {Name: "member", OrgId: ""}, {Name: rbac.RoleOrgAuditor(), OrgId: pd.OrganizationID.String()}, {Name: rbac.RoleOrgWorkspaceAccess(), OrgId: pd.OrganizationID.String()}},
|
||||
WorkspaceOwnerRbacRoles: []*sdkproto.Role{{Name: rbac.RoleOrgMember(), OrgId: pd.OrganizationID.String()}, {Name: "member", OrgId: ""}, {Name: rbac.RoleOrgAuditor(), OrgId: pd.OrganizationID.String()}, {Name: rbac.RoleOrgWorkspaceAccess(), OrgId: pd.OrganizationID.String()}, {Name: rbac.RoleAgentsAccess(), OrgId: pd.OrganizationID.String()}},
|
||||
}
|
||||
if prebuiltWorkspaceBuildStage == sdkproto.PrebuiltWorkspaceBuildStage_CLAIM {
|
||||
// For claimed prebuilds, we expect the prebuild state to be set to CLAIM
|
||||
|
||||
+47
-44
@@ -36,6 +36,7 @@ const (
|
||||
orgTemplateAdmin string = "organization-template-admin"
|
||||
orgWorkspaceCreationBan string = "organization-workspace-creation-ban"
|
||||
orgWorkspaceAccess string = "organization-workspace-access"
|
||||
agentsAccess string = "agents-access"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -175,6 +176,10 @@ func RoleOrgWorkspaceAccess() string {
|
||||
return orgWorkspaceAccess
|
||||
}
|
||||
|
||||
func RoleAgentsAccess() string {
|
||||
return agentsAccess
|
||||
}
|
||||
|
||||
// ScopedRoleOrgAdmin is the org role with the organization ID
|
||||
func ScopedRoleOrgAdmin(organizationID uuid.UUID) RoleIdentifier {
|
||||
return RoleIdentifier{Name: RoleOrgAdmin(), OrganizationID: organizationID}
|
||||
@@ -205,6 +210,10 @@ func ScopedRoleOrgWorkspaceAccess(organizationID uuid.UUID) RoleIdentifier {
|
||||
return RoleIdentifier{Name: RoleOrgWorkspaceAccess(), OrganizationID: organizationID}
|
||||
}
|
||||
|
||||
func ScopedRoleAgentsAccess(organizationID uuid.UUID) RoleIdentifier {
|
||||
return RoleIdentifier{Name: RoleAgentsAccess(), OrganizationID: organizationID}
|
||||
}
|
||||
|
||||
// DefaultOrgMemberRoles is the deployment-wide default for the
|
||||
// organizations.default_org_member_roles column, applied to every new
|
||||
// organization at creation time. The column has no SQL DEFAULT, so this
|
||||
@@ -213,7 +222,7 @@ func ScopedRoleOrgWorkspaceAccess(organizationID uuid.UUID) RoleIdentifier {
|
||||
// Returned as a fresh slice each call to prevent accidental mutation of
|
||||
// the shared default through append or index assignment.
|
||||
func DefaultOrgMemberRoles() []string {
|
||||
return []string{orgWorkspaceAccess}
|
||||
return []string{orgWorkspaceAccess, agentsAccess}
|
||||
}
|
||||
|
||||
// orgWorkspaceAccessMemberPerms returns the member-scoped permissions
|
||||
@@ -338,31 +347,9 @@ type RoleOptions struct {
|
||||
NoChatSharing bool
|
||||
}
|
||||
|
||||
// retiredRoleNames contains retired built-in role names. They stay reserved so
|
||||
// a custom role cannot take a name that older binaries still resolve as a
|
||||
// built-in role, which would silently shadow the custom permissions on
|
||||
// rollback. Role expansion and assignment validation keep treating retired
|
||||
// names as grants of nothing so a pre-reservation custom role with the same
|
||||
// name cannot be granted or activated again.
|
||||
var retiredRoleNames = map[string]struct{}{
|
||||
"agents-access": {},
|
||||
}
|
||||
|
||||
// IsRetiredRoleName reports whether name is a retired built-in role name.
|
||||
// Retired names are reserved against creation and updates and are excluded
|
||||
// from assignment and expansion; a custom role that took the name before it
|
||||
// was reserved remains deletable.
|
||||
func IsRetiredRoleName(name string) bool {
|
||||
_, ok := retiredRoleNames[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ReservedRoleName exists because the database should only allow unique role
|
||||
// names, but some roles are built in. So these names are reserved
|
||||
func ReservedRoleName(name string) bool {
|
||||
if _, ok := retiredRoleNames[name]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := loadBuiltinRoles()[name]
|
||||
return ok
|
||||
}
|
||||
@@ -743,6 +730,29 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
},
|
||||
}
|
||||
},
|
||||
// ActionDelete is intentionally excluded because hard-deletion goes through
|
||||
// ResourceSystem in dbpurge.
|
||||
agentsAccess: func(organizationID uuid.UUID) Role {
|
||||
return Role{
|
||||
Identifier: RoleIdentifier{Name: agentsAccess, OrganizationID: organizationID},
|
||||
DisplayName: "Coder Agents User",
|
||||
Site: []Permission{},
|
||||
User: []Permission{},
|
||||
ByOrgID: map[string]OrgPermissions{
|
||||
organizationID.String(): {
|
||||
Org: []Permission{},
|
||||
Member: Permissions(map[string][]policy.Action{
|
||||
ResourceChat.Type: {
|
||||
policy.ActionCreate,
|
||||
policy.ActionRead,
|
||||
policy.ActionShare,
|
||||
policy.ActionUpdate,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
builtInRoles.Store(&roles)
|
||||
@@ -765,6 +775,7 @@ var assignRoles = map[string]map[string]bool{
|
||||
orgTemplateAdmin: true,
|
||||
orgWorkspaceCreationBan: true,
|
||||
orgWorkspaceAccess: true,
|
||||
agentsAccess: true,
|
||||
templateAdmin: true,
|
||||
userAdmin: true,
|
||||
customSiteRole: true,
|
||||
@@ -781,6 +792,7 @@ var assignRoles = map[string]map[string]bool{
|
||||
orgTemplateAdmin: true,
|
||||
orgWorkspaceCreationBan: true,
|
||||
orgWorkspaceAccess: true,
|
||||
agentsAccess: true,
|
||||
templateAdmin: true,
|
||||
userAdmin: true,
|
||||
customSiteRole: true,
|
||||
@@ -790,6 +802,7 @@ var assignRoles = map[string]map[string]bool{
|
||||
member: true,
|
||||
orgMember: true,
|
||||
orgWorkspaceAccess: true,
|
||||
agentsAccess: true,
|
||||
},
|
||||
orgAdmin: {
|
||||
orgAdmin: true,
|
||||
@@ -799,11 +812,13 @@ var assignRoles = map[string]map[string]bool{
|
||||
orgTemplateAdmin: true,
|
||||
orgWorkspaceCreationBan: true,
|
||||
orgWorkspaceAccess: true,
|
||||
agentsAccess: true,
|
||||
customOrganizationRole: true,
|
||||
},
|
||||
orgUserAdmin: {
|
||||
orgMember: true,
|
||||
orgWorkspaceAccess: true,
|
||||
agentsAccess: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -988,12 +1003,6 @@ func RoleByName(name RoleIdentifier) (Role, error) {
|
||||
func rolesByNames(roleNames []RoleIdentifier) ([]Role, error) {
|
||||
roles := make([]Role, 0, len(roleNames))
|
||||
for _, n := range roleNames {
|
||||
if IsRetiredRoleName(n.Name) {
|
||||
// Retired role names grant nothing and cannot be re-created as
|
||||
// custom roles, so stale stored grants are dropped instead of
|
||||
// failing expansion.
|
||||
continue
|
||||
}
|
||||
r, err := RoleByName(n)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get role permissions: %w", err)
|
||||
@@ -1135,13 +1144,13 @@ type OrgRolePermissions struct {
|
||||
//
|
||||
// organization-member carries only the "floor": the minimum permission
|
||||
// set every member of an organization holds (read-self records,
|
||||
// notifications, and similar). It deliberately grants no workspace
|
||||
// access. The ability to create and use workspaces lives exclusively on
|
||||
// the organization-workspace-access role (see
|
||||
// orgWorkspaceAccessMemberPerms), which organizations attach to members
|
||||
// notifications, and similar). It deliberately grants no workspace or
|
||||
// Coder Agents chat access. Those live exclusively on the
|
||||
// organization-workspace-access role (see orgWorkspaceAccessMemberPerms)
|
||||
// and the agents-access role, which organizations attach to members
|
||||
// through default_org_member_roles or explicit assignment. This is what
|
||||
// makes restricted "gateway account" members possible: clear the
|
||||
// default roles and members keep the floor but cannot touch workspaces.
|
||||
// makes restricted members possible: remove a role from the defaults and
|
||||
// members keep the floor but lose that access unless granted explicitly.
|
||||
func OrgMemberPermissions(org OrgSettings) OrgRolePermissions {
|
||||
// Organization-level permissions that all org members get.
|
||||
orgPermMap := map[string][]policy.Action{
|
||||
@@ -1201,14 +1210,6 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions {
|
||||
ResourceNotificationMessage.Type: {policy.ActionRead, policy.ActionUpdate},
|
||||
ResourceNotificationPreference.Type: ResourceNotificationPreference.AvailableActions(),
|
||||
ResourceInboxNotification.Type: ResourceInboxNotification.AvailableActions(),
|
||||
|
||||
// Hard deletion is authorized through ResourceSystem in dbpurge.
|
||||
ResourceChat.Type: {
|
||||
policy.ActionCreate,
|
||||
policy.ActionRead,
|
||||
policy.ActionShare,
|
||||
policy.ActionUpdate,
|
||||
},
|
||||
})
|
||||
|
||||
if org.ShareableWorkspaceOwners != ShareableWorkspaceOwnersEveryone {
|
||||
@@ -1256,7 +1257,9 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions {
|
||||
})
|
||||
}
|
||||
|
||||
// Chat permissions are intentionally omitted for service accounts.
|
||||
// Chat permissions are intentionally omitted for service accounts, and
|
||||
// GetAuthorizationUserRoles does not union agents-access from the org
|
||||
// defaults for them, so chat requires an explicit agents-access grant.
|
||||
memberPerms := Permissions(map[string][]policy.Action{
|
||||
// Read-self org-member record.
|
||||
ResourceOrganizationMember.Type: {policy.ActionRead},
|
||||
|
||||
+56
-35
@@ -131,6 +131,8 @@ func TestChatSharingPermissions(t *testing.T) {
|
||||
|
||||
memberRole, err := rbac.RoleByName(rbac.RoleMember())
|
||||
require.NoError(t, err)
|
||||
agentsAccessRole, err := rbac.RoleByName(rbac.ScopedRoleAgentsAccess(orgID))
|
||||
require.NoError(t, err)
|
||||
perms := rbac.OrgMemberPermissions(rbac.OrgSettings{})
|
||||
orgMemberRole := rbac.Role{
|
||||
Identifier: rbac.ScopedRoleOrgMember(orgID),
|
||||
@@ -145,7 +147,7 @@ func TestChatSharingPermissions(t *testing.T) {
|
||||
auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry())
|
||||
return auth.Authorize(context.Background(), rbac.Subject{
|
||||
ID: userID,
|
||||
Roles: rbac.Roles{memberRole, orgMemberRole},
|
||||
Roles: rbac.Roles{memberRole, orgMemberRole, agentsAccessRole},
|
||||
Scope: rbac.ScopeAll,
|
||||
}, policy.ActionShare, resource)
|
||||
}
|
||||
@@ -234,7 +236,7 @@ func TestMemberRolesExcludeWorkspacePerms(t *testing.T) {
|
||||
member := rbac.OrgMemberPermissions(orgSettings).Member
|
||||
require.False(t, hasResource(member, rbac.ResourceWorkspace.Type), "organization-member must not grant workspace permissions")
|
||||
require.True(t, hasResource(member, rbac.ResourceOrganizationMember.Type), "organization-member should grant read-self")
|
||||
require.True(t, hasResource(member, rbac.ResourceChat.Type), "organization-member should grant chat access")
|
||||
require.False(t, hasResource(member, rbac.ResourceChat.Type), "organization-member must not grant chat access")
|
||||
|
||||
sa := rbac.OrgServiceAccountPermissions(orgSettings).Member
|
||||
require.False(t, hasResource(sa, rbac.ResourceWorkspace.Type), "organization-service-account must not grant workspace permissions")
|
||||
@@ -250,6 +252,37 @@ func TestMemberRolesExcludeWorkspacePerms(t *testing.T) {
|
||||
"organization-workspace-access should grant workspace permissions")
|
||||
}
|
||||
|
||||
func TestAgentsAccessRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
orgID := uuid.New()
|
||||
require.True(t, rbac.ReservedRoleName(rbac.RoleAgentsAccess()), "custom roles must not shadow agents-access")
|
||||
require.Equal(t, []string{rbac.RoleOrgWorkspaceAccess(), rbac.RoleAgentsAccess()}, rbac.DefaultOrgMemberRoles())
|
||||
|
||||
role, err := rbac.RoleByName(rbac.ScopedRoleAgentsAccess(orgID))
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, role.Site)
|
||||
require.Empty(t, role.User)
|
||||
require.Empty(t, role.ByOrgID[orgID.String()].Org)
|
||||
require.ElementsMatch(t, rbac.Permissions(map[string][]policy.Action{
|
||||
rbac.ResourceChat.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionShare, policy.ActionUpdate},
|
||||
}), role.ByOrgID[orgID.String()].Member)
|
||||
|
||||
// The role is organization scoped only.
|
||||
_, err = rbac.RoleByName(rbac.RoleIdentifier{Name: rbac.RoleAgentsAccess()})
|
||||
require.Error(t, err)
|
||||
|
||||
for _, assigner := range []rbac.RoleIdentifier{
|
||||
rbac.RoleOwner(),
|
||||
rbac.RoleUserAdmin(),
|
||||
rbac.ScopedRoleOrgAdmin(orgID),
|
||||
rbac.ScopedRoleOrgUserAdmin(orgID),
|
||||
} {
|
||||
require.True(t, rbac.CanAssignRole(rbac.RoleIdentifiers{assigner}, rbac.ScopedRoleAgentsAccess(orgID)), assigner.String())
|
||||
}
|
||||
require.False(t, rbac.CanAssignRole(rbac.RoleIdentifiers{rbac.ScopedRoleOrgTemplateAdmin(orgID)}, rbac.ScopedRoleAgentsAccess(orgID)))
|
||||
}
|
||||
|
||||
// These were "pared down" in https://github.com/coder/coder/pull/21359 to avoid
|
||||
// using the now DB-backed organization-member role. As a result, they no longer
|
||||
// model real-world org-scoped users (who also have organization-member).
|
||||
@@ -313,6 +346,21 @@ func TestRolePermissions(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
orgAgentsAccessUser := func() authSubject {
|
||||
memberRole, err := rbac.RoleByName(rbac.RoleMember())
|
||||
require.NoError(t, err)
|
||||
agentsAccessRole, err := rbac.RoleByName(rbac.ScopedRoleAgentsAccess(orgID))
|
||||
require.NoError(t, err)
|
||||
return authSubject{
|
||||
Name: "org_agents_access",
|
||||
Actor: rbac.Subject{
|
||||
ID: currentUser.String(),
|
||||
Roles: rbac.Roles{memberRole, agentsAccessRole},
|
||||
Scope: rbac.ScopeAll,
|
||||
}.WithCachedASTValue(),
|
||||
}
|
||||
}()
|
||||
|
||||
orgMemberMe := func() authSubject {
|
||||
memberRole, err := rbac.RoleByName(rbac.RoleMember())
|
||||
require.NoError(t, err)
|
||||
@@ -1411,8 +1459,8 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
|
||||
Resource: rbac.ResourceChat.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, orgMemberMe},
|
||||
false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser},
|
||||
true: {owner, orgAdmin, orgAgentsAccessUser},
|
||||
false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1420,8 +1468,8 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionShare},
|
||||
Resource: rbac.ResourceChat.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, orgMemberMe},
|
||||
false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser},
|
||||
true: {owner, orgAdmin, orgAgentsAccessUser},
|
||||
false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1430,7 +1478,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceChat.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin},
|
||||
false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser},
|
||||
false: {setOtherOrg, memberMe, orgMemberMe, orgAgentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1617,6 +1665,7 @@ func TestListRoles(t *testing.T) {
|
||||
fmt.Sprintf("organization-template-admin:%s", orgID.String()),
|
||||
fmt.Sprintf("organization-workspace-creation-ban:%s", orgID.String()),
|
||||
fmt.Sprintf("organization-workspace-access:%s", orgID.String()),
|
||||
fmt.Sprintf("agents-access:%s", orgID.String()),
|
||||
},
|
||||
orgRoleNames)
|
||||
}
|
||||
@@ -1795,31 +1844,3 @@ func TestDBPurgeBoundaryLogDelete(t *testing.T) {
|
||||
rbac.ResourceBoundaryLog)
|
||||
require.Error(t, err, "DBPurge must not read boundary logs")
|
||||
}
|
||||
|
||||
// TestRetiredRoleNames verifies retired built-in role names stay reserved and
|
||||
// that stale stored grants of them expand to nothing instead of failing.
|
||||
func TestRetiredRoleNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const retiredName = "agents-access"
|
||||
orgID := uuid.New()
|
||||
|
||||
require.True(t, rbac.IsRetiredRoleName(retiredName))
|
||||
// Retired names stay reserved so custom roles cannot shadow them.
|
||||
require.True(t, rbac.ReservedRoleName(retiredName))
|
||||
|
||||
// Retired names do not resolve to a role...
|
||||
_, err := rbac.RoleByName(rbac.RoleIdentifier{Name: retiredName})
|
||||
require.Error(t, err)
|
||||
|
||||
// ...but stored role arrays may still contain them, so expansion drops
|
||||
// them instead of failing.
|
||||
roles, err := rbac.RoleIdentifiers{
|
||||
{Name: retiredName},
|
||||
{Name: retiredName, OrganizationID: orgID},
|
||||
rbac.ScopedRoleOrgAuditor(orgID),
|
||||
}.Expand()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, roles, 1)
|
||||
require.Equal(t, rbac.ScopedRoleOrgAuditor(orgID), roles[0].Identifier)
|
||||
}
|
||||
|
||||
@@ -82,14 +82,6 @@ func Expand(ctx context.Context, db database.Store, names []rbac.RoleIdentifier)
|
||||
roles := make([]rbac.Role, 0, len(names))
|
||||
|
||||
for _, name := range names {
|
||||
if rbac.IsRetiredRoleName(name.Name) {
|
||||
// Retired built-in role names may linger in stored role arrays
|
||||
// until a cleanup migration lands. They grant nothing and stay
|
||||
// reserved, so skip them instead of falling through to the
|
||||
// custom-role lookup, which could resurrect an unrelated
|
||||
// pre-reservation custom role with the same name.
|
||||
continue
|
||||
}
|
||||
// Remove any built in roles
|
||||
expanded, err := rbac.RoleByName(name)
|
||||
if err == nil {
|
||||
|
||||
@@ -46,30 +46,6 @@ func TestExpandCustomRoleRoles(t *testing.T) {
|
||||
require.Len(t, roles, 1, "role found")
|
||||
}
|
||||
|
||||
func TestExpandRetiredRoleName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
|
||||
// Simulate a custom role that took the name before it became a
|
||||
// reserved built-in role name. Expanding a stored grant of the
|
||||
// retired name must not resurrect the custom role.
|
||||
dbgen.CustomRole(t, db, database.CustomRole{
|
||||
Name: "agents-access",
|
||||
OrganizationID: uuid.NullUUID{
|
||||
UUID: org.ID,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
roles, err := rolestore.Expand(ctx, db, []rbac.RoleIdentifier{{Name: "agents-access", OrganizationID: org.ID}})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, roles, "retired role names expand to nothing")
|
||||
}
|
||||
|
||||
func TestPrefetchCustomRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -101,13 +101,6 @@ func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role, customR
|
||||
}
|
||||
|
||||
for _, role := range customRoles {
|
||||
// A custom role that predates the reservation of a retired
|
||||
// built-in name can no longer be granted, so do not offer it.
|
||||
// Custom-role management surfaces still list it so it can be
|
||||
// deleted.
|
||||
if rbac.IsRetiredRoleName(role.Name) {
|
||||
continue
|
||||
}
|
||||
canAssign := rbac.CanAssignRole(actorRoles, rbac.CustomSiteRole())
|
||||
if role.RoleIdentifier().IsOrgRole() {
|
||||
canAssign = rbac.CanAssignRole(actorRoles, rbac.CustomOrganizationRole(role.OrganizationID.UUID))
|
||||
|
||||
@@ -7,9 +7,9 @@ const (
|
||||
RoleTemplateAdmin string = "template-admin"
|
||||
RoleUserAdmin string = "user-admin"
|
||||
RoleAuditor string = "auditor"
|
||||
// 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.
|
||||
RoleAgentsAccess string = "agents-access"
|
||||
|
||||
RoleOrganizationAdmin string = "organization-admin"
|
||||
|
||||
@@ -64,6 +64,7 @@ Coder ships the following organization roles:
|
||||
- **Organization Auditor**: reads the organization's audit logs and connection logs, along with the resources those logs reference.
|
||||
- **Organization Workspace Access**: creates and operates the user's own workspaces in the organization.
|
||||
- **Organization Workspace Creation Ban**: blocks creating and deleting workspaces in the organization, and overrides any role that would otherwise allow it.
|
||||
- **Coder Agents User**: uses [Coder Agents](../../ai-coder/agents/index.md) in the organization. Members other than service accounts hold this role through the organization's [default member roles](./organizations.md#default-member-roles) unless an administrator removes it.
|
||||
|
||||
Organization Admin doesn't include SSH, application, or terminal access to workspaces other members own.
|
||||
A user with that role can read, build, stop, and delete those workspaces.
|
||||
|
||||
@@ -133,12 +133,18 @@ names. Coder unions this list into every member's effective roles at request
|
||||
time, so changes propagate to all current and future members on their next
|
||||
request without re-issuing tokens or editing per-user role assignments.
|
||||
|
||||
The default value is `["organization-workspace-access"]`. With that default,
|
||||
every organization member can read, build, ssh into, and execute commands in
|
||||
workspaces they own. Removing `organization-workspace-access` from the list
|
||||
creates organization members that cannot create or use workspaces unless the
|
||||
role is assigned to them directly, which is useful for restricted accounts
|
||||
that should only hold the minimal member permissions.
|
||||
The default value is `["organization-workspace-access", "agents-access"]`.
|
||||
With that default, every organization member can read, build, ssh into, and
|
||||
execute commands in workspaces they own, and can use Coder Agents. Removing
|
||||
`organization-workspace-access` from the list creates organization members
|
||||
that cannot create or use workspaces unless the role is assigned to them
|
||||
directly, which is useful for restricted accounts that should only hold the
|
||||
minimal member permissions. Removing `agents-access` limits Coder Agents to
|
||||
members who hold the role directly. Refer to
|
||||
[Control who can use Coder Agents](../../ai-coder/agents/getting-started.md#control-who-can-use-coder-agents).
|
||||
|
||||
Service accounts don't inherit `agents-access` from this list. Assign the
|
||||
role to a service account directly to let it use Coder Agents.
|
||||
|
||||
To edit the default roles in the dashboard, go to
|
||||
**Admin settings** > **Organizations** > **Roles** > **Default Roles**.
|
||||
@@ -147,7 +153,7 @@ From the CLI, use `coder organizations edit`:
|
||||
|
||||
```shell
|
||||
coder organizations edit --org <organization> \
|
||||
--default-org-member-roles organization-workspace-access,organization-template-admin
|
||||
--default-org-member-roles organization-workspace-access,agents-access,organization-template-admin
|
||||
```
|
||||
|
||||
The flag replaces the current list rather than adding to it, and accepts a
|
||||
|
||||
@@ -23,8 +23,10 @@ Before you begin, confirm the following:
|
||||
The **Organization Admin** role and the **Owner** role include this access.
|
||||
A custom role with model configuration access also works.
|
||||
- **Organization membership** for each user who uses Coder Agents.
|
||||
Every organization member can use Coder Agents by default.
|
||||
To restrict access, see [Control who can use Coder Agents](#control-who-can-use-coder-agents).
|
||||
Users also need read access to at least one model in the organization.
|
||||
New models are shared with the whole organization by default; to restrict who can use Coder Agents, narrow the model access lists.
|
||||
New models are shared with the whole organization by default.
|
||||
See [Manage model permissions](./models.md#manage-model-permissions).
|
||||
|
||||
## Step 1: Configure an LLM provider and model
|
||||
@@ -72,6 +74,34 @@ When the conversation and available tools are sufficient, it works without provi
|
||||
If missing tools, skills, MCP integrations, or context block progress, the agent selects a template and provisions a workspace to continue.
|
||||
It also provisions a workspace for tasks that need file access, command execution, or code changes.
|
||||
|
||||
## Control who can use Coder Agents
|
||||
|
||||
Organization members can use Coder Agents by default.
|
||||
Access comes from the **Coder Agents User** (`agents-access`) organization role, which each organization grants to members through its [default member roles](../../admin/users/organizations.md#default-member-roles).
|
||||
|
||||
Members who don't hold the role directly lose Coder Agents access when you remove **Coder Agents User** from the default roles.
|
||||
Role sync assigns roles at sign-in and doesn't store default roles, so members who get the role through role sync regain access at their next sign-in, and mapping the role in advance doesn't shorten the gap.
|
||||
|
||||
To limit Coder Agents to a subset of members in an organization (requires a Premium license):
|
||||
|
||||
1. Remove **Coder Agents User** from the organization's default roles.
|
||||
In the dashboard, go to **Admin settings** > **Organizations** > **Roles** > **Default Roles**.
|
||||
From the CLI, run `coder organizations edit --org <organization> --default-org-member-roles organization-workspace-access`, keeping any other default roles in the list.
|
||||
1. Grant `agents-access` to the members who need it:
|
||||
- With [IdP organization role sync](../../admin/users/idp-sync.md#role-sync), map an IdP group or role to `agents-access`.
|
||||
- Without role sync, assign **Coder Agents User** from **Admin settings** > **Organizations** > **Members**.
|
||||
|
||||
Keep the following behavior in mind:
|
||||
|
||||
- Removing a user from the mapped IdP group or role removes their Coder Agents access at their next sign-in.
|
||||
- Owners and Organization Admins can always use Coder Agents, whatever the default roles are.
|
||||
- Service accounts don't inherit `agents-access` from the default roles.
|
||||
Assign the role to a service account directly if it needs Coder Agents.
|
||||
- Members without the role can't use Coder Agents or open chats they created earlier.
|
||||
Their chats are kept and become available again when they regain the role.
|
||||
A chat shared with them stays readable.
|
||||
- [Model permissions](./models.md#manage-model-permissions) still apply: users also need read access to at least one model.
|
||||
|
||||
## Optimize your templates
|
||||
|
||||
The agent selects templates based on their **name, description, and README**.
|
||||
@@ -197,6 +227,8 @@ Good starting points:
|
||||
- **Prototyping** — building proof-of-concept implementations, simple
|
||||
dashboards, internal tools.
|
||||
|
||||
To limit Coder Agents to the pilot group, see [Control who can use Coder Agents](#control-who-can-use-coder-agents).
|
||||
|
||||
Set expectations for how the team reviews agent output.
|
||||
Developers should still review all agent-produced code before merging.
|
||||
The agent is a force multiplier, not a replacement for developer judgment.
|
||||
|
||||
@@ -148,8 +148,9 @@ Create, update, delete, and share permissions control their corresponding action
|
||||
|
||||
Members with model share permission can let members and groups in the selected organization use the model.
|
||||
|
||||
Model access lists control who can use Coder Agents.
|
||||
All organization members except service accounts hold chat permissions, but a member without read access to at least one model in the organization can't use the feature.
|
||||
Model access lists control which models a member can use in Coder Agents.
|
||||
Chat permissions come from the **Coder Agents User** role, which every member except service accounts holds by default; see [Control who can use Coder Agents](./getting-started.md#control-who-can-use-coder-agents).
|
||||
A member without read access to at least one model in the organization can't use the feature.
|
||||
New models grant read access to the whole organization by default.
|
||||
|
||||
1. Navigate to **Admin settings** > **AI** > **Models**.
|
||||
|
||||
Generated
+6
-2
@@ -19,7 +19,11 @@ coder organizations edit [flags]
|
||||
```console
|
||||
- Replace the roles every member of the organization holds:
|
||||
|
||||
$ coder organizations edit --default-org-member-roles organization-workspace-access,organization-template-admin
|
||||
$ coder organizations edit --default-org-member-roles organization-workspace-access,agents-access,organization-template-admin
|
||||
|
||||
- Restrict Coder Agents to members assigned agents-access directly:
|
||||
|
||||
$ coder organizations edit --default-org-member-roles organization-workspace-access
|
||||
|
||||
- Grant members no roles at all:
|
||||
|
||||
@@ -34,7 +38,7 @@ coder organizations edit [flags]
|
||||
|------|---------------------------|
|
||||
| Type | <code>string-array</code> |
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
Generated
+3
-3
@@ -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";
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export 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": "Coder Agents User can use Coder Agents chat.",
|
||||
"organization-admin":
|
||||
"Organization admin can manage all resources within this organization.",
|
||||
"organization-user-admin":
|
||||
@@ -52,6 +53,7 @@ const roleNamesByAccessLevel: readonly string[] = [
|
||||
"organization-template-admin",
|
||||
"auditor",
|
||||
"organization-auditor",
|
||||
"agents-access",
|
||||
"member",
|
||||
"organization-member",
|
||||
];
|
||||
|
||||
@@ -434,12 +434,17 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
// keeps its loading state instead of flashing the provisional organization's
|
||||
// catalog before permissions resolve.
|
||||
const isModelDataPending = !orgSelectionSettled || isModelCatalogLoading;
|
||||
const modelSelectorPlaceholder = getModelSelectorPlaceholder(
|
||||
modelOptions,
|
||||
isModelDataPending,
|
||||
hasConfiguredModels,
|
||||
modelCatalog,
|
||||
);
|
||||
const isForbidden = !canCreateChat || noPermittedOrgs;
|
||||
// A forbidden user may have no organization to read models from, and the
|
||||
// catalog-based placeholder would then wrongly report that none exist.
|
||||
const modelSelectorPlaceholder = isForbidden
|
||||
? "Select model"
|
||||
: getModelSelectorPlaceholder(
|
||||
modelOptions,
|
||||
isModelDataPending,
|
||||
hasConfiguredModels,
|
||||
modelCatalog,
|
||||
);
|
||||
const modelSelectorHelp = getModelSelectorHelp({
|
||||
isModelCatalogLoading: isModelDataPending,
|
||||
hasModelOptions,
|
||||
@@ -492,8 +497,6 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
setUserSelectedModel(value);
|
||||
};
|
||||
|
||||
const isForbidden = !canCreateChat || noPermittedOrgs;
|
||||
|
||||
// Filter workspaces by the selected organization. We use
|
||||
// client-side filtering of the full "owner:me" fetch rather
|
||||
// than re-querying with an org filter because it avoids
|
||||
|
||||
@@ -5,7 +5,9 @@ import { Link } from "#/components/Link/Link";
|
||||
import { docs } from "#/utils/docs";
|
||||
|
||||
export const ChatAccessDeniedAlert: FC = () => {
|
||||
const docsLink = docs("/ai-coder/agents/getting-started");
|
||||
const docsLink = docs(
|
||||
"/ai-coder/agents/getting-started#control-who-can-use-coder-agents",
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert
|
||||
@@ -18,7 +20,8 @@ export const ChatAccessDeniedAlert: FC = () => {
|
||||
>
|
||||
<AlertTitle>Permission required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You don't have permission to use Coder Agents. Contact your Coder
|
||||
You don't have permission to use Coder Agents. Your existing chats are
|
||||
kept and reappear when your access is restored. Contact your Coder
|
||||
administrator, then refresh this page.{" "}
|
||||
<Link href={docsLink} target="_blank" rel="noreferrer">
|
||||
View Docs
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ import { action } from "storybook/actions";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import type { AssignableRoles } from "#/api/typesGenerated";
|
||||
import {
|
||||
MockAgentsAccessRole,
|
||||
MockOrganization,
|
||||
MockOrganizationAuditorRole,
|
||||
MockPermissions,
|
||||
@@ -22,6 +23,7 @@ const mockOrgRoles: AssignableRoles[] = [
|
||||
assignable: true,
|
||||
built_in: true,
|
||||
},
|
||||
MockAgentsAccessRole,
|
||||
{
|
||||
name: "organization-admin",
|
||||
display_name: "Organization Admin",
|
||||
|
||||
@@ -204,9 +204,8 @@ const DefaultRolesSection: FC<DefaultRolesSectionProps> = ({
|
||||
<div className="text-sm">
|
||||
{organization.default_org_member_roles.length === 0 ? (
|
||||
<span className="text-content-secondary">
|
||||
No default roles. Members have only the permissions of their
|
||||
directly assigned roles, which excludes creating and using
|
||||
workspaces.
|
||||
No default roles. Members can create and use workspaces or use Coder
|
||||
Agents only through directly assigned roles.
|
||||
</span>
|
||||
) : (
|
||||
<DefaultRolesSummary
|
||||
|
||||
@@ -79,7 +79,8 @@ const ActiveDefaultRolesDialog: FC<ActiveProps> = ({
|
||||
These roles are granted to every member of this organization,
|
||||
current and future. Removing a role removes it from all members that
|
||||
are not assigned that role directly. Without Organization Workspace
|
||||
Access, members cannot create or use workspaces.
|
||||
Access, members cannot create or use workspaces. Without Coder
|
||||
Agents User, members cannot use Coder Agents.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<RoleSelector
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { SlimRole } from "#/api/typesGenerated";
|
||||
import { HttpResponse, http, type PathParams } from "msw";
|
||||
import type { SlimRole, UpdateRoles } from "#/api/typesGenerated";
|
||||
import {
|
||||
MockAgentsAccessRole,
|
||||
MockEntitlementsWithMultiOrg,
|
||||
MockOrganization,
|
||||
MockOrganizationAuditorRole,
|
||||
MockOrganizationMember,
|
||||
MockOrganizationMember2,
|
||||
MockOrganizationPermissions,
|
||||
MockUserMember,
|
||||
} from "#/testHelpers/entities";
|
||||
@@ -123,6 +126,42 @@ describe("OrganizationMembersPage", () => {
|
||||
await updateUserRole(MockOrganizationAuditorRole);
|
||||
await screen.findByText(/TestUser2's roles have been updated\./);
|
||||
});
|
||||
|
||||
it("grants agents-access explicitly to a service account", async () => {
|
||||
let requestedRoles: readonly string[] = [];
|
||||
server.use(
|
||||
http.get("/api/v2/organizations/:organizationId/members/roles", () =>
|
||||
HttpResponse.json([
|
||||
MockOrganizationAuditorRole,
|
||||
MockAgentsAccessRole,
|
||||
]),
|
||||
),
|
||||
http.get(
|
||||
"/api/v2/organizations/:organizationId/paginated-members",
|
||||
() =>
|
||||
HttpResponse.json({
|
||||
members: [
|
||||
MockOrganizationMember,
|
||||
{ ...MockOrganizationMember2, is_service_account: true },
|
||||
],
|
||||
count: 2,
|
||||
}),
|
||||
),
|
||||
http.put<PathParams, UpdateRoles>(
|
||||
`/api/v2/organizations/:organizationId/members/${MockUserMember.id}/roles`,
|
||||
async ({ request }) => {
|
||||
requestedRoles = (await request.json()).roles;
|
||||
return HttpResponse.json(MockOrganizationMember2);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await renderPage();
|
||||
await updateUserRole(MockAgentsAccessRole);
|
||||
await waitFor(() =>
|
||||
expect(requestedRoles).toEqual([MockAgentsAccessRole.name]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when it fails", () => {
|
||||
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
updateOrganizationMemberRoles,
|
||||
} from "#/api/queries/organizations";
|
||||
import { organizationRoles } from "#/api/queries/roles";
|
||||
import type {
|
||||
AssignableRoles,
|
||||
OrganizationMemberWithUserData,
|
||||
User,
|
||||
import {
|
||||
type AssignableRoles,
|
||||
type OrganizationMemberWithUserData,
|
||||
RoleAgentsAccess,
|
||||
type User,
|
||||
} from "#/api/typesGenerated";
|
||||
import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog";
|
||||
import { EmptyState } from "#/components/EmptyState/EmptyState";
|
||||
@@ -143,7 +144,14 @@ const OrganizationMembersPage: FC = () => {
|
||||
key={memberToEditRoles?.username}
|
||||
user={memberToEditRoles}
|
||||
availableRoles={organizationRolesQuery.data}
|
||||
additionalImpliedRoles={defaultMemberImpliedRoles}
|
||||
// Service accounts do not inherit agents-access from the org defaults.
|
||||
additionalImpliedRoles={
|
||||
memberToEditRoles?.is_service_account
|
||||
? defaultMemberImpliedRoles.filter(
|
||||
(r) => r.name !== RoleAgentsAccess,
|
||||
)
|
||||
: defaultMemberImpliedRoles
|
||||
}
|
||||
onCancel={() => setMemberToEditRoles(undefined)}
|
||||
onUpdateRoles={async (roles) => {
|
||||
try {
|
||||
|
||||
@@ -21,7 +21,7 @@ export const MockOrganization: TypesGen.Organization = {
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
is_default: false,
|
||||
default_org_member_roles: ["organization-workspace-access"],
|
||||
default_org_member_roles: ["organization-workspace-access", "agents-access"],
|
||||
};
|
||||
|
||||
export const MockDefaultOrganization: TypesGen.Organization = {
|
||||
@@ -378,6 +378,18 @@ export const MockOrganizationAuditorRole: TypesGen.AssignableRoles = {
|
||||
organization_member_permissions: [],
|
||||
};
|
||||
|
||||
export const MockAgentsAccessRole: TypesGen.AssignableRoles = {
|
||||
name: "agents-access",
|
||||
display_name: "Coder Agents User",
|
||||
assignable: true,
|
||||
built_in: true,
|
||||
site_permissions: [],
|
||||
user_permissions: [],
|
||||
organization_id: MockOrganization.id,
|
||||
organization_permissions: [],
|
||||
organization_member_permissions: [],
|
||||
};
|
||||
|
||||
export const MockRoleWithOrgPermissions: TypesGen.AssignableRoles = {
|
||||
name: "my-role-1",
|
||||
display_name: "My Role 1",
|
||||
|
||||
Reference in New Issue
Block a user