feat(coderd): gate org-member workspace elevation behind experiment (#26027)

Gates the workspace-ops elevation on `organization-member` and `organization-service-account` behind the `minimum-implicit-member` experiment.
This commit is contained in:
Steven Masley
2026-06-05 15:31:33 -05:00
committed by GitHub
parent 8a5e04e90f
commit f17f8392bd
4 changed files with 102 additions and 14 deletions
+9 -7
View File
@@ -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")
}
+13
View File
@@ -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()
}
+24 -7
View File
@@ -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)
+56
View File
@@ -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).