From 52423eb87b99132cfd0f8d4c7838a746b7e62d12 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 3 Aug 2026 16:56:56 -0400 Subject: [PATCH] feat: promote MinimumImplicitMember experiment to GA (#27472) Promotes the `minimum-implicit-member` experiment to GA and removes it. ## What changes - The `minimum-implicit-member` experiment constant, its `RoleOptions.MinimumImplicitMember` toggle, and the global `rbac.MinimumImplicitMember()` accessor are deleted. The minimal-member behavior is now the only behavior: `organization-member` and `organization-service-account` carry only the floor (read-self records, notifications, and similar) and grant **no workspace permissions**. Workspace access lives exclusively on the `organization-workspace-access` role. - The experiment gate on customizing `default_org_member_roles` (`PATCH /organizations/{org}`) is removed; the built-in-roles-only validation remains. - The dashboard's Default Roles section and the implied-roles display on the members page are no longer experiment-gated. - Admin docs: new "Default member roles" section in `docs/admin/users/organizations.md`, cross-linked from `groups-roles.md`. ## Why this is safe for existing deployments Migration `000516` (shipped earlier) backfilled `default_org_member_roles` with `['organization-workspace-access']` on every organization. Members therefore keep exactly the effective permissions they had with the experiment off; the workspace elevation flows through the default role instead of being baked into `organization-member`. **Rollback caveat:** rolling back past this release restores the bundled elevation, silently re-granting workspace access to members of organizations that cleared their default roles. ## Review Deep-review R1 findings are addressed in `chore: address deep-review findings` (copy fixes, read-only Default Roles for viewers, removable overlapping explicit grants, RBAC prose restoration, test de-tautologizing, docs). Point-by-point disposition is in the PR comments. --- Generated by Coder Agents on behalf of @Emyrk. --- coderd/apidoc/docs.go | 4 -- coderd/apidoc/swagger.json | 4 -- coderd/coderd.go | 18 +++-- coderd/rbac/authz_internal_test.go | 7 ++ coderd/rbac/object.go | 13 ---- coderd/rbac/roles.go | 62 +++++++---------- coderd/rbac/roles_test.go | 69 +++++++------------ coderd/workspaceconnwatcher/watcher_test.go | 8 ++- codersdk/deployment.go | 8 +-- docs/admin/users/groups-roles.md | 5 ++ docs/admin/users/organizations.md | 32 +++++++++ docs/install/releases/feature-stages.md | 4 +- docs/reference/api/schemas.md | 6 +- .../coderd/license/usercount_bench_test.go | 5 -- enterprise/coderd/license/usercount_test.go | 57 ++++++--------- enterprise/coderd/organizations.go | 13 ---- enterprise/coderd/organizations_test.go | 35 +--------- site/src/api/typesGenerated.ts | 2 - site/src/modules/roles/RoleSelectorDialog.tsx | 12 +++- .../CustomRolesPage/CustomRolesPage.tsx | 4 +- .../CustomRolesPageView.stories.tsx | 29 ++------ .../CustomRolesPage/CustomRolesPageView.tsx | 42 ++++++----- .../CustomRolesPage/DefaultRolesDialog.tsx | 6 +- .../OrganizationMembersPage.tsx | 12 +--- 24 files changed, 189 insertions(+), 268 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 10bcead80d..9f7da2078c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20352,7 +20352,6 @@ const docTemplate = `{ "mcp-server-http", "workspace-build-updates", "nats_pubsub", - "minimum-implicit-member", "workspace-capable-licensing", "ai-gateway-seat-exclusion", "chat-advisor", @@ -20367,7 +20366,6 @@ const docTemplate = `{ "ExperimentChatVirtualDesktop": "Enables virtual desktop and computer use provider for agents.", "ExperimentExample": "This isn't used for anything.", "ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.", - "ExperimentMinimumImplicitMember": "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", "ExperimentNATSPubsub": "Enables embedded NATS pubsub.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentOAuth2": "Enables OAuth2 provider functionality.", @@ -20384,7 +20382,6 @@ const docTemplate = `{ "Enables the MCP HTTP server functionality.", "Enables publishing workspace build updates to the all builds pubsub channel.", "Enables embedded NATS pubsub.", - "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", "Counts only users holding the workspace-create permission toward the license seat limit.", "Excludes AI Gateway (AI Bridge) usage from AI Governance seat consumption.", "Enables the advisor tool for root agent chats.", @@ -20400,7 +20397,6 @@ const docTemplate = `{ "ExperimentMCPServerHTTP", "ExperimentWorkspaceBuildUpdates", "ExperimentNATSPubsub", - "ExperimentMinimumImplicitMember", "ExperimentWorkspaceCapableLicensing", "ExperimentAIGatewaySeatExclusion", "ExperimentChatAdvisor", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 45f4da4f2f..f3bfe8591d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18486,7 +18486,6 @@ "mcp-server-http", "workspace-build-updates", "nats_pubsub", - "minimum-implicit-member", "workspace-capable-licensing", "ai-gateway-seat-exclusion", "chat-advisor", @@ -18501,7 +18500,6 @@ "ExperimentChatVirtualDesktop": "Enables virtual desktop and computer use provider for agents.", "ExperimentExample": "This isn't used for anything.", "ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.", - "ExperimentMinimumImplicitMember": "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", "ExperimentNATSPubsub": "Enables embedded NATS pubsub.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentOAuth2": "Enables OAuth2 provider functionality.", @@ -18518,7 +18516,6 @@ "Enables the MCP HTTP server functionality.", "Enables publishing workspace build updates to the all builds pubsub channel.", "Enables embedded NATS pubsub.", - "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", "Counts only users holding the workspace-create permission toward the license seat limit.", "Excludes AI Gateway (AI Bridge) usage from AI Governance seat consumption.", "Enables the advisor tool for root agent chats.", @@ -18534,7 +18531,6 @@ "ExperimentMCPServerHTTP", "ExperimentWorkspaceBuildUpdates", "ExperimentNATSPubsub", - "ExperimentMinimumImplicitMember", "ExperimentWorkspaceCapableLicensing", "ExperimentAIGatewaySeatExclusion", "ExperimentChatAdvisor", diff --git a/coderd/coderd.go b/coderd/coderd.go index d1bcf883e7..2f49c5f00f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -384,13 +384,17 @@ func New(options *Options) *API { options.Logger, options.DeploymentValues.Experiments.Value(), ) - if bool(options.DeploymentValues.DisableOwnerWorkspaceExec) || bool(options.DeploymentValues.DisableWorkspaceSharing) || bool(options.DeploymentValues.DisableChatSharing) || experiments.Enabled(codersdk.ExperimentMinimumImplicitMember) { - rbac.ReloadBuiltinRoles(&rbac.RoleOptions{ - NoOwnerWorkspaceExec: bool(options.DeploymentValues.DisableOwnerWorkspaceExec), - NoWorkspaceSharing: bool(options.DeploymentValues.DisableWorkspaceSharing), - NoChatSharing: bool(options.DeploymentValues.DisableChatSharing), - MinimumImplicitMember: experiments.Enabled(codersdk.ExperimentMinimumImplicitMember), - }) + // Only reload when an option deviates from the defaults so the zero + // value keeps the stock builtin roles. Constructing the full options + // struct here (rather than mirroring individual fields in the guard) + // ensures a newly added RoleOptions field cannot be silently ignored. + roleOptions := rbac.RoleOptions{ + NoOwnerWorkspaceExec: bool(options.DeploymentValues.DisableOwnerWorkspaceExec), + NoWorkspaceSharing: bool(options.DeploymentValues.DisableWorkspaceSharing), + NoChatSharing: bool(options.DeploymentValues.DisableChatSharing), + } + if roleOptions != (rbac.RoleOptions{}) { + rbac.ReloadBuiltinRoles(&roleOptions) } if options.DeploymentValues.DisableWorkspaceSharing { diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 221082bdea..8316724508 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -307,6 +307,7 @@ func TestAuthorizeDomain(t *testing.T) { Roles: Roles{ must(RoleByName(RoleMember())), orgMemberRole(defOrg), + must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))), }, } @@ -467,6 +468,7 @@ func TestAuthorizeDomain(t *testing.T) { Roles: Roles{ must(RoleByName(ScopedRoleOrgAdmin(defOrg))), orgMemberRole(defOrg), + must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))), must(RoleByName(RoleMember())), }, } @@ -545,6 +547,7 @@ func TestAuthorizeDomain(t *testing.T) { Scope: must(ExpandScope(ScopeApplicationConnect)), Roles: Roles{ orgMemberRole(defOrg), + must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))), must(RoleByName(RoleMember())), }, } @@ -1049,6 +1052,7 @@ func TestAuthorizeScope(t *testing.T) { Roles: Roles{ must(RoleByName(RoleMember())), orgMemberRole(defOrg), + must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))), }, Scope: must(ExpandScope(ScopeApplicationConnect)), } @@ -1085,6 +1089,7 @@ func TestAuthorizeScope(t *testing.T) { Roles: Roles{ must(RoleByName(RoleMember())), orgMemberRole(defOrg), + must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))), }, Scope: Scope{ Role: Role{ @@ -1174,6 +1179,7 @@ func TestAuthorizeScope(t *testing.T) { Roles: Roles{ must(RoleByName(RoleMember())), orgMemberRole(defOrg), + must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))), }, Scope: Scope{ Role: Role{ @@ -1229,6 +1235,7 @@ func TestAuthorizeScope(t *testing.T) { Roles: Roles{ must(RoleByName(RoleMember())), orgMemberRole(defOrg), + must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))), }, Scope: must(ScopeNoUserData.Expand()), } diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index d84eccd032..2994a8bfd9 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -267,16 +267,3 @@ func SetChatACLDisabled(v bool) { func ChatACLDisabled() bool { return chatACLDisabled.Load() } - -// minimumImplicitMember mirrors RoleOptions.MinimumImplicitMember. -// Stored as a global because OrgMemberPermissions and -// OrgServiceAccountPermissions are called from rolestore without -// access to api instance state. -var minimumImplicitMember atomic.Bool - -// MinimumImplicitMember reports whether the workspace-ops elevation -// has been stripped from organization-member and -// organization-service-account. See RoleOptions.MinimumImplicitMember. -func MinimumImplicitMember() bool { - return minimumImplicitMember.Load() -} diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 403384a462..fdb462b7eb 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "slices" - "sort" "strconv" "strings" "sync/atomic" @@ -223,9 +222,14 @@ func DefaultOrgMemberRoles() []string { return []string{orgWorkspaceAccess} } -// OrgWorkspaceAccessMemberPerms returns the elevation perms granted by the -// organization-workspace-access role. -func OrgWorkspaceAccessMemberPerms() []Permission { +// orgWorkspaceAccessMemberPerms returns the member-scoped permissions +// granted by the organization-workspace-access role: the ability to +// create and operate your own workspaces in the organization. The +// organization-member role intentionally does not include these +// permissions (see OrgMemberPermissions), so workspace access is only +// held by members that have this role, typically through the +// organization's default_org_member_roles. +func orgWorkspaceAccessMemberPerms() []Permission { return Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: ResourceWorkspace.AvailableActions(), @@ -340,14 +344,6 @@ type RoleOptions struct { NoOwnerWorkspaceExec bool NoWorkspaceSharing bool NoChatSharing bool - - // MinimumImplicitMember removes the workspace-ops elevation - // (OrgWorkspaceAccessMemberPerms) from organization-member and - // organization-service-account. With it set, those two roles carry - // only the floor, and the elevation must be granted explicitly via - // the organization-workspace-access role (typically attached - // through default_org_member_roles). - MinimumImplicitMember bool } // ReservedRoleName exists because the database should only allow unique role @@ -369,8 +365,6 @@ func ReloadBuiltinRoles(opts *RoleOptions) { opts = &RoleOptions{} } - minimumImplicitMember.Store(opts.MinimumImplicitMember) - denyPermissions := []Permission{} if opts.NoWorkspaceSharing { denyPermissions = append(denyPermissions, Permission{ @@ -728,7 +722,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ByOrgID: map[string]OrgPermissions{ organizationID.String(): { Org: []Permission{}, - Member: OrgWorkspaceAccessMemberPerms(), + Member: orgWorkspaceAccessMemberPerms(), }, }, } @@ -1074,8 +1068,8 @@ func Permissions(perms map[string][]policy.Action) []Permission { } } // Deterministic ordering of permissions - sort.Slice(list, func(i, j int) bool { - return list[i].ResourceType < list[j].ResourceType + slices.SortFunc(list, func(a, b Permission) int { + return strings.Compare(a.ResourceType, b.ResourceType) }) return list } @@ -1144,6 +1138,16 @@ type OrgRolePermissions struct { // OrgMemberPermissions returns the permissions for the organization-member // system role, which can vary based on the organization's workspace sharing // settings. +// +// 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 +// 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. func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { // Organization-level permissions that all org members get. orgPermMap := map[string][]policy.Action{ @@ -1184,7 +1188,7 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { // Chat access requires the agents-access role and is intentionally // not granted in the floor. - floor := Permissions(map[string][]policy.Action{ + memberPerms := Permissions(map[string][]policy.Action{ // Read-self org-member record. ResourceOrganizationMember.Type: {policy.ActionRead}, @@ -1207,19 +1211,6 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { ResourceInboxNotification.Type: ResourceInboxNotification.AvailableActions(), }) - // Workspace-ops elevation. When MinimumImplicitMember is off, the - // elevation is bundled into organization-member here. When on, the - // elevation lives exclusively on organization-workspace-access; a - // user without that role then has only the floor. See - // OrgWorkspaceAccessMemberPerms for the perm set and the - // "Intentionally omitted" rationale. - var elevation []Permission - if !MinimumImplicitMember() { - elevation = OrgWorkspaceAccessMemberPerms() - } - - memberPerms := slices.Concat(elevation, floor) - if org.ShareableWorkspaceOwners != ShareableWorkspaceOwnersEveryone { memberPerms = append(memberPerms, Permission{ Negate: true, @@ -1265,7 +1256,7 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { }) } - floor := Permissions(map[string][]policy.Action{ + memberPerms := Permissions(map[string][]policy.Action{ // Read-self org-member record. ResourceOrganizationMember.Type: {policy.ActionRead}, @@ -1289,12 +1280,5 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { ResourceInboxNotification.Type: ResourceInboxNotification.AvailableActions(), }) - var elevation []Permission - if !MinimumImplicitMember() { - elevation = OrgWorkspaceAccessMemberPerms() - } - - memberPerms := slices.Concat(elevation, floor) - return OrgRolePermissions{Org: orgPerms, Member: memberPerms} } diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index fb8b836db0..c5c484b01b 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -203,60 +203,41 @@ func TestOwnerExec(t *testing.T) { }) } -// TestMinimumImplicitMember verifies the floor/elevation gate on -// organization-member and organization-service-account. When the option -// is off (default), both roles carry the workspace-ops elevation. When -// on, both roles carry only the floor and the elevation must be -// granted explicitly via organization-workspace-access. +// TestMemberRolesExcludeWorkspacePerms verifies that organization-member +// and organization-service-account grant no workspace permissions, and +// that the registered organization-workspace-access role is what carries +// them. // -//nolint:tparallel,paralleltest -func TestMinimumImplicitMember(t *testing.T) { +// Reads the global builtin role registry via RoleByName, which sibling +// tests reload, so it must run serially. +// +//nolint:paralleltest +func TestMemberRolesExcludeWorkspacePerms(t *testing.T) { orgSettings := rbac.OrgSettings{ ShareableWorkspaceOwners: rbac.ShareableWorkspaceOwnersEveryone, } hasResource := func(perms []rbac.Permission, resource string) bool { - for _, p := range perms { - if p.ResourceType == resource && !p.Negate { - return true - } - } - return false + return slices.ContainsFunc(perms, func(p rbac.Permission) bool { + return p.ResourceType == resource && !p.Negate + }) } - // ResourceWorkspace is granted by the elevation - // (OrgWorkspaceAccessMemberPerms) and not by the floor, so it acts as - // a witness for whether the elevation is bundled in. - elevationWitness := rbac.ResourceWorkspace.Type - // ResourceOrganizationMember is part of the floor; floor must remain - // regardless of the option. - floorWitness := rbac.ResourceOrganizationMember.Type + 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") - t.Run("Off", func(t *testing.T) { - rbac.ReloadBuiltinRoles(nil) - t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) + sa := rbac.OrgServiceAccountPermissions(orgSettings).Member + require.False(t, hasResource(sa, rbac.ResourceWorkspace.Type), "organization-service-account must not grant workspace permissions") + require.True(t, hasResource(sa, rbac.ResourceOrganizationMember.Type), "organization-service-account should grant read-self") - member := rbac.OrgMemberPermissions(orgSettings).Member - require.True(t, hasResource(member, elevationWitness), "organization-member should include the elevation when MinimumImplicitMember is off") - require.True(t, hasResource(member, floorWitness), "organization-member should include the floor") - - sa := rbac.OrgServiceAccountPermissions(orgSettings).Member - require.True(t, hasResource(sa, elevationWitness), "organization-service-account should include the elevation when MinimumImplicitMember is off") - require.True(t, hasResource(sa, floorWitness), "organization-service-account should include the floor") - }) - - t.Run("On", func(t *testing.T) { - rbac.ReloadBuiltinRoles(&rbac.RoleOptions{MinimumImplicitMember: true}) - t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) - - member := rbac.OrgMemberPermissions(orgSettings).Member - require.False(t, hasResource(member, elevationWitness), "organization-member should drop the elevation when MinimumImplicitMember is on") - require.True(t, hasResource(member, floorWitness), "organization-member should still include the floor") - - sa := rbac.OrgServiceAccountPermissions(orgSettings).Member - require.False(t, hasResource(sa, elevationWitness), "organization-service-account should drop the elevation when MinimumImplicitMember is on") - require.True(t, hasResource(sa, floorWitness), "organization-service-account should still include the floor") - }) + // The registered organization-workspace-access role is the grant + // path for workspace permissions. + orgID := uuid.New() + wsAccess, err := rbac.RoleByName(rbac.ScopedRoleOrgWorkspaceAccess(orgID)) + require.NoError(t, err) + require.True(t, hasResource(wsAccess.ByOrgID[orgID.String()].Member, rbac.ResourceWorkspace.Type), + "organization-workspace-access should grant workspace permissions") } // These were "pared down" in https://github.com/coder/coder/pull/21359 to avoid diff --git a/coderd/workspaceconnwatcher/watcher_test.go b/coderd/workspaceconnwatcher/watcher_test.go index 9c0434bc34..786e6ae792 100644 --- a/coderd/workspaceconnwatcher/watcher_test.go +++ b/coderd/workspaceconnwatcher/watcher_test.go @@ -489,12 +489,18 @@ func memberSubject(userID, orgID uuid.UUID) rbac.Subject { if err != nil { panic(err) } + // organization-workspace-access carries the workspace perms; the + // organization-member role alone is only the floor. + wsAccess, err := rbac.RoleByName(rbac.ScopedRoleOrgWorkspaceAccess(orgID)) + if err != nil { + panic(err) + } return rbac.Subject{ FriendlyName: "coderdtest-member", Email: "member@coderd.test", Type: rbac.SubjectTypeUser, ID: userID.String(), - Roles: rbac.Roles{memberRole, orgMember}, + Roles: rbac.Roles{memberRole, orgMember, wsAccess}, Scope: rbac.ScopeAll, }.WithCachedASTValue() } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 5d6c624d20..dd756a036e 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5362,7 +5362,6 @@ const ( ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality. ExperimentWorkspaceBuildUpdates Experiment = "workspace-build-updates" // Enables publishing workspace build updates to the all builds pubsub channel. ExperimentNATSPubsub Experiment = "nats_pubsub" // Enables embedded NATS pubsub. - ExperimentMinimumImplicitMember Experiment = "minimum-implicit-member" // Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts. ExperimentWorkspaceCapableLicensing Experiment = "workspace-capable-licensing" // Counts only users holding the workspace-create permission toward the license seat limit. ExperimentAIGatewaySeatExclusion Experiment = "ai-gateway-seat-exclusion" // Excludes AI Gateway (AI Bridge) usage from AI Governance seat consumption. ExperimentChatAdvisor Experiment = "chat-advisor" // Enables the advisor tool for root agent chats. @@ -5388,8 +5387,6 @@ func (e Experiment) DisplayName() string { return "Workspace Build Updates Channel" case ExperimentNATSPubsub: return "NATS Pubsub" - case ExperimentMinimumImplicitMember: - return "Gateway Accounts (minimum implicit member)" case ExperimentWorkspaceCapableLicensing: return "Workspace-Capable Licensing" case ExperimentAIGatewaySeatExclusion: @@ -5418,7 +5415,6 @@ var ExperimentsKnown = Experiments{ ExperimentMCPServerHTTP, ExperimentNATSPubsub, ExperimentWorkspaceBuildUpdates, - ExperimentMinimumImplicitMember, ExperimentWorkspaceCapableLicensing, ExperimentAIGatewaySeatExclusion, ExperimentChatAdvisor, @@ -5430,9 +5426,7 @@ var ExperimentsKnown = Experiments{ // users to opt-in to via --experimental='*'. // Experiments that are not ready for consumption by all users should // not be included here and will be essentially hidden. -var ExperimentsSafe = Experiments{ - ExperimentMinimumImplicitMember, -} +var ExperimentsSafe = Experiments{} // Experiments is a list of experiments. // Multiple experiments may be enabled at the same time. diff --git a/docs/admin/users/groups-roles.md b/docs/admin/users/groups-roles.md index 84f3c898ef..57d6721cb0 100644 --- a/docs/admin/users/groups-roles.md +++ b/docs/admin/users/groups-roles.md @@ -31,6 +31,11 @@ Roles determine which actions users can take within the platform. A user may have one or more roles. All users have an implicit Member role that may use personal workspaces. +Whether organization members can create and use workspaces is controlled per +organization through its default member roles. See +[Default member roles](./organizations.md#default-member-roles) for how to +remove workspace operations from the default member set. + ## Custom Roles > [!NOTE] diff --git a/docs/admin/users/organizations.md b/docs/admin/users/organizations.md index e9e5701e82..a5d9fe9cc4 100644 --- a/docs/admin/users/organizations.md +++ b/docs/admin/users/organizations.md @@ -120,6 +120,38 @@ their organization. Users can be in multiple organizations. ![Workspace List](../../images/admin/users/organizations/workspace-list.png) +## Default member roles + +> [!NOTE] +> Editing default member roles requires a Premium license. +> ([learn more](https://coder.com/pricing#compare-plans)). + +Each organization carries a `default_org_member_roles` list of built-in role +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. + +To edit the default roles in the dashboard, go to +**Admin settings** > **Organizations** > **Roles** > **Default Roles**, or +set `default_org_member_roles` via +`PATCH /organizations/{organization}`. + +### Limitations + +- `default_org_member_roles` accepts built-in role names only. Custom + organization roles are rejected; assign them directly to members instead. +- Removing a role from the list removes it from every member that does not + hold the role through a direct assignment, including existing members. + Review the per-organization [audit log](../security/audit-logs.md) if you + need to trace who changed the defaults. + ## Next steps - [Organizations - best practices](../../tutorials/best-practices/organizations.md) diff --git a/docs/install/releases/feature-stages.md b/docs/install/releases/feature-stages.md index edc596ec79..3748d580a1 100644 --- a/docs/install/releases/feature-stages.md +++ b/docs/install/releases/feature-stages.md @@ -64,9 +64,7 @@ You can opt-out of a feature after you've enabled it. -| Feature | Description | -|---------------------------|-------------------------------------------------------------------------------------------------------------| -| `minimum-implicit-member` | Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts. | +Currently no experimental features are available. ## Beta diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 663769eee5..db722b7cc9 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7533,9 +7533,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o #### Enumerated Values -| Value(s) | -|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `agent-lifecycle-hooks`, `ai-gateway-seat-exclusion`, `auto-fill-parameters`, `chat-advisor`, `chat-virtual-desktop`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-capable-licensing`, `workspace-usage` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `agent-lifecycle-hooks`, `ai-gateway-seat-exclusion`, `auto-fill-parameters`, `chat-advisor`, `chat-virtual-desktop`, `example`, `mcp-server-http`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-capable-licensing`, `workspace-usage` | ## codersdk.ExternalAPIKeyScopes diff --git a/enterprise/coderd/license/usercount_bench_test.go b/enterprise/coderd/license/usercount_bench_test.go index 4e635d04ef..af1856624d 100644 --- a/enterprise/coderd/license/usercount_bench_test.go +++ b/enterprise/coderd/license/usercount_bench_test.go @@ -39,11 +39,6 @@ import ( // // go test ./enterprise/coderd/license/ -bench BenchmarkCountWorkspaceCapableUsers -benchtime 5x -run '^$' -v func BenchmarkCountWorkspaceCapableUsers(b *testing.B) { - // Workspace-create flows only through explicit grants under - // MinimumImplicitMember, so capability actually varies between users. - rbac.ReloadBuiltinRoles(&rbac.RoleOptions{MinimumImplicitMember: true}) - b.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) - ctx := context.Background() authorizer := rbac.NewCachingAuthorizer(prometheus.NewRegistry()) // Discard logs: the per-count Info line and its fields are not what diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index 5275109645..abf799b523 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -28,12 +28,8 @@ import ( // counting: only users the RBAC engine authorizes to create workspaces // consume seats, so members without workspace-create ("gateway accounts") // are excluded. -// -// The subtests toggle the global builtin roles via ReloadBuiltinRoles, so -// they must run serially. -// -//nolint:tparallel,paralleltest func TestCountWorkspaceCapableUsers(t *testing.T) { + t.Parallel() ctx := context.Background() authorizer := rbac.NewCachingAuthorizer(prometheus.NewRegistry()) @@ -61,14 +57,12 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { require.NoError(t, err) } - t.Run("ElevationBundledParity", func(t *testing.T) { - // MinimumImplicitMember off (default): organization-member bundles - // the workspace-ops elevation, so every active org member counts + t.Run("DefaultRolesParity", func(t *testing.T) { + t.Parallel() + // Orgs keep the default default_org_member_roles, which include + // organization-workspace-access, so every active org member counts // and the workspace-capable count matches the legacy count except // for zero-org plain members. - rbac.ReloadBuiltinRoles(nil) - t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) - db, _ := dbtestutil.NewDB(t) org := dbgen.Organization(t, db, database.Organization{}) @@ -108,14 +102,12 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { require.Equal(t, int64(4), count, "zero-org plain member must not count") }) - t.Run("MinimumImplicitMember", func(t *testing.T) { - // MinimumImplicitMember on: organization-member carries only the - // floor. Workspace-create flows exclusively through the - // organization-workspace-access role, granted explicitly or via - // default_org_member_roles. - rbac.ReloadBuiltinRoles(&rbac.RoleOptions{MinimumImplicitMember: true}) - t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) - + t.Run("EmptyDefaultRoles", func(t *testing.T) { + t.Parallel() + // organization-member carries no workspace permissions on its + // own. With default_org_member_roles cleared, workspace-create + // flows exclusively through an explicit + // organization-workspace-access grant. db, _ := dbtestutil.NewDB(t) org := dbgen.Organization(t, db, database.Organization{}) emptyDefaultRoles(t, db, org) @@ -153,11 +145,9 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { }) t.Run("MultiOrgSplitCapability", func(t *testing.T) { + t.Parallel() // Users whose capability differs between their organizations: // workspace-create in any one org is sufficient to be counted. - rbac.ReloadBuiltinRoles(&rbac.RoleOptions{MinimumImplicitMember: true}) - t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) - db, _ := dbtestutil.NewDB(t) orgA := dbgen.Organization(t, db, database.Organization{}) orgB := dbgen.Organization(t, db, database.Organization{}) @@ -186,9 +176,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { }) t.Run("CustomOrgRole", func(t *testing.T) { - rbac.ReloadBuiltinRoles(&rbac.RoleOptions{MinimumImplicitMember: true}) - t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) - + t.Parallel() db, _ := dbtestutil.NewDB(t) org := dbgen.Organization(t, db, database.Organization{}) emptyDefaultRoles(t, db, org) @@ -229,9 +217,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { }) t.Run("MalformedRoleNotCounted", func(t *testing.T) { - rbac.ReloadBuiltinRoles(nil) - t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) - + t.Parallel() db, _ := dbtestutil.NewDB(t) org := dbgen.Organization(t, db, database.Organization{}) @@ -251,12 +237,10 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { }) t.Run("EntitlementsAddonGate", func(t *testing.T) { + t.Parallel() // Permission-based counting is gated on both the experiment and a // valid license carrying the AI Governance addon. Without either, // the legacy active user count applies. - rbac.ReloadBuiltinRoles(&rbac.RoleOptions{MinimumImplicitMember: true}) - t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) - db, _ := dbtestutil.NewDB(t) org := dbgen.Organization(t, db, database.Organization{}) emptyDefaultRoles(t, db, org) @@ -313,6 +297,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { }) t.Run("LicensesEntitlementsCountFn", func(t *testing.T) { + t.Parallel() // Exercises LicensesEntitlements directly: the count function is // only invoked when a valid license carries the addon, grace // period licenses still gate the count, and count errors fall @@ -629,24 +614,22 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { // TestCountWorkspaceCapableUsersErrors covers the count's database // failure paths, which abort the count rather than skewing it. -// -// Reads the builtin role registry that sibling tests reload, so it must -// run serially. -// -//nolint:paralleltest func TestCountWorkspaceCapableUsersErrors(t *testing.T) { + t.Parallel() ctx := context.Background() authorizer := rbac.NewCachingAuthorizer(prometheus.NewRegistry()) prefetchParams := database.CustomRolesParams{IncludeSystemRoles: true} t.Run("NilAuthorizer", func(t *testing.T) { + t.Parallel() mDB := dbmock.NewMockStore(gomock.NewController(t)) _, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), mDB, nil) require.ErrorContains(t, err, "dev error") }) t.Run("PrefetchError", func(t *testing.T) { + t.Parallel() mDB := dbmock.NewMockStore(gomock.NewController(t)) mDB.EXPECT().CustomRoles(gomock.Any(), prefetchParams).Return(nil, xerrors.New("boom")) @@ -655,6 +638,7 @@ func TestCountWorkspaceCapableUsersErrors(t *testing.T) { }) t.Run("RolesQueryError", func(t *testing.T) { + t.Parallel() mDB := dbmock.NewMockStore(gomock.NewController(t)) mDB.EXPECT().CustomRoles(gomock.Any(), prefetchParams).Return([]database.CustomRole{}, nil) mDB.EXPECT().GetActiveUsersAuthorizationRoles(gomock.Any()).Return(nil, xerrors.New("boom")) @@ -664,6 +648,7 @@ func TestCountWorkspaceCapableUsersErrors(t *testing.T) { }) t.Run("ExpandLookupError", func(t *testing.T) { + t.Parallel() // A custom role that was not prefetched (deleted, or created // mid-count) is looked up individually; a database failure there // aborts the count. diff --git a/enterprise/coderd/organizations.go b/enterprise/coderd/organizations.go index a63e722823..0b1eacd292 100644 --- a/enterprise/coderd/organizations.go +++ b/enterprise/coderd/organizations.go @@ -4,7 +4,6 @@ import ( "database/sql" "fmt" "net/http" - "slices" "strings" "github.com/google/uuid" @@ -62,18 +61,6 @@ func (api *API) patchOrganization(rw http.ResponseWriter, r *http.Request) { return } - // Deviations from rbac.DefaultOrgMemberRoles require the - // minimum-implicit-member experiment. - if req.DefaultOrgMemberRoles != nil && - !slices.Equal(*req.DefaultOrgMemberRoles, rbac.DefaultOrgMemberRoles()) && - !api.AGPL.Experiments.Enabled(codersdk.ExperimentMinimumImplicitMember) { - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "Changing default organization roles is not enabled on this deployment.", - Detail: fmt.Sprintf("Setting default_org_member_roles to anything other than %v requires the %q experiment.", rbac.DefaultOrgMemberRoles(), codersdk.ExperimentMinimumImplicitMember), - }) - return - } - // default_org_member_roles currently accepts built-in role names only. // Custom (DB-stored) roles are intentionally rejected here so the // caller cannot land a malformed name that would break role expansion diff --git a/enterprise/coderd/organizations_test.go b/enterprise/coderd/organizations_test.go index 97e2090989..a0c3c26f96 100644 --- a/enterprise/coderd/organizations_test.go +++ b/enterprise/coderd/organizations_test.go @@ -453,7 +453,7 @@ func TestPatchOrganizationsByUser(t *testing.T) { t.Run("DefaultOrgMemberRoles", func(t *testing.T) { t.Parallel() - t.Run("EqualToDefaultAllowedWithoutExperiment", func(t *testing.T) { + t.Run("EqualToDefaultAllowed", func(t *testing.T) { t.Parallel() client, _ := coderdenttest.New(t, &coderdenttest.Options{ LicenseOptions: &coderdenttest.LicenseOptions{ @@ -474,7 +474,7 @@ func TestPatchOrganizationsByUser(t *testing.T) { require.Equal(t, rbac.DefaultOrgMemberRoles(), updated.DefaultOrgMemberRoles) }) - t.Run("DeviationRejectedWithoutExperiment", func(t *testing.T) { + t.Run("DeviationAllowed", func(t *testing.T) { t.Parallel() client, _ := coderdenttest.New(t, &coderdenttest.Options{ LicenseOptions: &coderdenttest.LicenseOptions{ @@ -486,33 +486,7 @@ func TestPatchOrganizationsByUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) o := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) - // Empty array represents a Gateway Accounts organization. Without - // the experiment, this must be rejected. - //nolint:gocritic // Only owners can update organization settings. - _, err := client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{ - DefaultOrgMemberRoles: ptr.Ref([]string{}), - }) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) - require.Contains(t, apiErr.Message, "Changing default organization roles is not enabled") - }) - - t.Run("DeviationAllowedWithExperiment", func(t *testing.T) { - t.Parallel() - dv := coderdtest.DeploymentValues(t) - dv.Experiments = []string{string(codersdk.ExperimentMinimumImplicitMember)} - client, _ := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{DeploymentValues: dv}, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureMultipleOrganizations: 1, - }, - }, - }) - ctx := testutil.Context(t, testutil.WaitMedium) - o := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) - + // Empty array represents a Gateway Accounts organization. //nolint:gocritic // Only owners can update organization settings. updated, err := client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{ DefaultOrgMemberRoles: ptr.Ref([]string{}), @@ -523,10 +497,7 @@ func TestPatchOrganizationsByUser(t *testing.T) { t.Run("NonBuiltInRoleRejected", func(t *testing.T) { t.Parallel() - dv := coderdtest.DeploymentValues(t) - dv.Experiments = []string{string(codersdk.ExperimentMinimumImplicitMember)} client, _ := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{DeploymentValues: dv}, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ codersdk.FeatureMultipleOrganizations: 1, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 25bd954011..11aae90673 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5143,7 +5143,6 @@ export type Experiment = | "chat-virtual-desktop" | "example" | "mcp-server-http" - | "minimum-implicit-member" | "nats_pubsub" | "notifications" | "oauth2" @@ -5159,7 +5158,6 @@ export const Experiments: Experiment[] = [ "chat-virtual-desktop", "example", "mcp-server-http", - "minimum-implicit-member", "nats_pubsub", "notifications", "oauth2", diff --git a/site/src/modules/roles/RoleSelectorDialog.tsx b/site/src/modules/roles/RoleSelectorDialog.tsx index 803f921671..04fcf3b85c 100644 --- a/site/src/modules/roles/RoleSelectorDialog.tsx +++ b/site/src/modules/roles/RoleSelectorDialog.tsx @@ -69,6 +69,16 @@ const ActiveRoleSelectorDialog: React.FC> = ({ const [selectedRoles, setSelectedRoles] = useState>( () => new Set(getRoleNames(user.roles)), ); + // A role the user holds explicitly must stay selectable even when it + // is also implied (for example via the organization's default roles); + // otherwise the explicit grant could never be removed here. Derived + // from the user's initial roles so rows do not vanish mid-edit. + const [impliedRoles] = useState(() => { + const explicitRoleNames = new Set(getRoleNames(user.roles)); + return additionalImpliedRoles.filter( + (role) => !explicitRoleNames.has(role.name), + ); + }); return ( > = ({ diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx index 12339d9c8b..42a0d0d3b8 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -27,8 +27,7 @@ const CustomRolesPage: FC = () => { organization: string; }; const { organization, organizationPermissions } = useOrganizationSettings(); - const { experiments, entitlements } = useDashboard(); - const defaultRolesEnabled = experiments.includes("minimum-implicit-member"); + const { entitlements } = useDashboard(); const defaultRolesEntitled = entitlements.features.multiple_organizations.enabled; @@ -98,7 +97,6 @@ const CustomRolesPage: FC = () => { canDeleteOrgRole={organizationPermissions?.deleteOrgRoles ?? false} canEditDefaultRoles={organizationPermissions?.editSettings ?? false} isCustomRolesEnabled={isCustomRolesEnabled} - defaultRolesEnabled={defaultRolesEnabled} defaultRolesEntitled={defaultRolesEntitled} availableOrgRoles={organizationRolesQuery.data} isUpdatingDefaultRoles={updateOrganizationMutation.isPending} diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx index a66529ff24..5368db952e 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx @@ -109,23 +109,8 @@ export const EmptyTableUserWithPermission: Story = { }, }; -export const DefaultRolesHidden: Story = { +export const DefaultRolesSection: Story = { args: { - defaultRolesEnabled: false, - availableOrgRoles: mockOrgRoles, - onUpdateDefaultRoles: async () => { - action("onUpdateDefaultRoles")(); - }, - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - expect(body.queryByText("Default Roles")).toBeNull(); - }, -}; - -export const DefaultRolesEnabled: Story = { - args: { - defaultRolesEnabled: true, defaultRolesEntitled: true, availableOrgRoles: mockOrgRoles, onUpdateDefaultRoles: async () => { @@ -136,7 +121,6 @@ export const DefaultRolesEnabled: Story = { export const DefaultRolesNotEntitled: Story = { args: { - defaultRolesEnabled: true, defaultRolesEntitled: false, availableOrgRoles: mockOrgRoles, onUpdateDefaultRoles: async () => { @@ -159,7 +143,6 @@ export const DefaultRolesEmpty: Story = { ...MockOrganization, default_org_member_roles: [], }, - defaultRolesEnabled: true, defaultRolesEntitled: true, availableOrgRoles: mockOrgRoles, onUpdateDefaultRoles: async () => { @@ -168,9 +151,8 @@ export const DefaultRolesEmpty: Story = { }, }; -export const DefaultRolesHiddenWithoutEditPermission: Story = { +export const DefaultRolesReadOnlyWithoutEditPermission: Story = { args: { - defaultRolesEnabled: true, defaultRolesEntitled: true, canEditDefaultRoles: false, availableOrgRoles: mockOrgRoles, @@ -180,13 +162,16 @@ export const DefaultRolesHiddenWithoutEditPermission: Story = { }, play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - expect(body.queryByText("Default Roles")).toBeNull(); + // The section is visible read-only; only the edit button is hidden. + await body.findByText("Default Roles"); + expect( + body.queryByRole("button", { name: /edit default roles/i }), + ).toBeNull(); }, }; export const DefaultRolesEditDialog: Story = { args: { - defaultRolesEnabled: true, defaultRolesEntitled: true, availableOrgRoles: mockOrgRoles, onUpdateDefaultRoles: async () => { diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index 2ebe55b3aa..b239d163af 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -39,7 +39,6 @@ interface CustomRolesPageViewProps { canDeleteOrgRole: boolean; canEditDefaultRoles: boolean; isCustomRolesEnabled: boolean; - defaultRolesEnabled?: boolean; defaultRolesEntitled?: boolean; availableOrgRoles?: AssignableRoles[]; onUpdateDefaultRoles?: (roles: string[]) => Promise; @@ -56,15 +55,11 @@ export const CustomRolesPageView: FC = ({ canDeleteOrgRole, canEditDefaultRoles, isCustomRolesEnabled, - defaultRolesEnabled, defaultRolesEntitled, availableOrgRoles, onUpdateDefaultRoles, isUpdatingDefaultRoles, }) => { - const showDefaultRoles = - defaultRolesEnabled && canEditDefaultRoles && Boolean(onUpdateDefaultRoles); - return (
{!isCustomRolesEnabled && ( @@ -74,10 +69,11 @@ export const CustomRolesPageView: FC = ({ documentationLink={docs("/admin/users/groups-roles")} /> )} - {showDefaultRoles && onUpdateDefaultRoles && ( + {onUpdateDefaultRoles && ( = ({ interface DefaultRolesSectionProps { organization: Organization; availableOrgRoles?: AssignableRoles[]; + canEditDefaultRoles: boolean; defaultRolesEntitled: boolean; isUpdatingDefaultRoles: boolean; onUpdateDefaultRoles: (roles: string[]) => Promise; @@ -138,6 +135,7 @@ interface DefaultRolesSectionProps { const DefaultRolesSection: FC = ({ organization, availableOrgRoles, + canEditDefaultRoles, defaultRolesEntitled, isUpdatingDefaultRoles, onUpdateDefaultRoles, @@ -153,23 +151,33 @@ const DefaultRolesSection: FC = ({ {!defaultRolesEntitled && } - Roles attached to every member of this organization. An empty - selection limits new members to the floor permissions only. + Roles granted to every member of this organization, current and + future, in addition to any roles assigned directly. Removing a role + here removes it from all members that are not assigned that role + directly. - + {canEditDefaultRoles && ( + + )}
{organization.default_org_member_roles.length === 0 ? ( - No default roles. New members receive only the floor. + No default roles. Members have only the permissions of their + directly assigned roles, which excludes creating and using + workspaces. ) : ( = ({ Edit default roles - These roles are attached to every member of this organization. Use - an empty selection to grant new members only the floor. + 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. { organization: string; }; const { organization, organizationPermissions } = useOrganizationSettings(); - const { entitlements, experiments } = useDashboard(); + const { entitlements } = useDashboard(); const searchParamsResult = useSearchParams(); const showAISeatColumn = shouldShowAISeatColumn(entitlements); - const defaultRolesEnabled = experiments.includes("minimum-implicit-member"); const organizationRolesQuery = useQuery(organizationRoles(organizationName)); const groupsByUserIdQuery = useQuery( @@ -81,9 +80,6 @@ const OrganizationMembersPage: FC = () => { // Resolve the org's default member role names against the assignable // roles list so the dialog can show full display names + descriptions. const defaultMemberImpliedRoles = useMemo(() => { - if (!defaultRolesEnabled) { - return []; - } const available = organizationRolesQuery.data; if (!available) { return []; @@ -91,11 +87,7 @@ const OrganizationMembersPage: FC = () => { return (organization?.default_org_member_roles ?? []) .map((name) => available.find((r) => r.name === name)) .filter((r): r is AssignableRoles => r !== undefined); - }, [ - defaultRolesEnabled, - organization?.default_org_member_roles, - organizationRolesQuery.data, - ]); + }, [organization?.default_org_member_roles, organizationRolesQuery.data]); if (!organization) { return ;