diff --git a/coderd/coderd.go b/coderd/coderd.go index 875fad3c05..b2d50f7068 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -348,11 +348,16 @@ func New(options *Options) *API { panic("developer error: options.PrometheusRegistry is nil and not running a unit test") } - if options.DeploymentValues.DisableOwnerWorkspaceExec || options.DeploymentValues.DisableWorkspaceSharing || options.DeploymentValues.DisableChatSharing { + experiments := ReadExperiments( + 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), + NoOwnerWorkspaceExec: bool(options.DeploymentValues.DisableOwnerWorkspaceExec), + NoWorkspaceSharing: bool(options.DeploymentValues.DisableWorkspaceSharing), + NoChatSharing: bool(options.DeploymentValues.DisableChatSharing), + MinimumImplicitMember: experiments.Enabled(codersdk.ExperimentMinimumImplicitMember), }) } @@ -391,9 +396,6 @@ func New(options *Options) *API { options.IDPSync = idpsync.NewAGPLSync(options.Logger, options.RuntimeConfig, idpsync.FromDeploymentValues(options.DeploymentValues)) } - experiments := ReadExperiments( - options.Logger, options.DeploymentValues.Experiments.Value(), - ) if options.AppHostname != "" && options.AppHostnameRegex == nil || options.AppHostname == "" && options.AppHostnameRegex != nil { panic("coderd: both AppHostname and AppHostnameRegex must be set or unset") } diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 2994a8bfd9..d84eccd032 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -267,3 +267,16 @@ 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 80610d53de..c67f3f22cc 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -320,6 +320,14 @@ 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 @@ -341,6 +349,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) { opts = &RoleOptions{} } + minimumImplicitMember.Store(opts.MinimumImplicitMember) + denyPermissions := []Permission{} if opts.NoWorkspaceSharing { denyPermissions = append(denyPermissions, Permission{ @@ -1171,12 +1181,16 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { ResourceInboxNotification.Type: ResourceInboxNotification.AvailableActions(), }) - // Workspace-ops elevation. Today bundled into organization-member; - // the minimum-implicit-member experiment will move the binding - // exclusively onto organization-workspace-access so a user without - // that role has only the floor. See OrgWorkspaceAccessMemberPerms - // for the perm set and the "Intentionally omitted" rationale. - elevation := OrgWorkspaceAccessMemberPerms() + // 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) @@ -1249,7 +1263,10 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { ResourceInboxNotification.Type: ResourceInboxNotification.AvailableActions(), }) - elevation := OrgWorkspaceAccessMemberPerms() + var elevation []Permission + if !MinimumImplicitMember() { + elevation = OrgWorkspaceAccessMemberPerms() + } memberPerms := slices.Concat(elevation, floor) diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index a5e3d77756..9b0054d97b 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -203,6 +203,62 @@ 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. +// +//nolint:tparallel,paralleltest +func TestMinimumImplicitMember(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 + } + + // 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 + + t.Run("Off", func(t *testing.T) { + rbac.ReloadBuiltinRoles(nil) + t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) + + 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") + }) +} + // 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).