From cccdf1ecac805fd8b83ad2e05b8747968fc2f933 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 27 Feb 2025 05:23:18 -0600 Subject: [PATCH] feat: implement WorkspaceCreationBan org role (#16686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using negative permissions, this role prevents a user's ability to create & delete a workspace within a given organization. Workspaces are uniquely owned by an org and a user, so the org has to supercede the user permission with a negative permission. # Use case Organizations must be able to restrict a member's ability to create a workspace. This permission is implicitly granted (see https://github.com/coder/coder/issues/16546#issuecomment-2655437860). To revoke this permission, the solution chosen was to use negative permissions in a built in role called `WorkspaceCreationBan`. # Rational Using negative permissions is new territory, and not ideal. However, workspaces are in a unique position. Workspaces have 2 owners. The organization and the user. To prevent users from creating a workspace in another organization, an [implied negative permission](https://github.com/coder/coder/blob/36d9f5ddb3d98029fee07d004709e1e51022e979/coderd/rbac/policy.rego#L172-L192) is used. So the truth table looks like: _how to read this table [here](https://github.com/coder/coder/blob/36d9f5ddb3d98029fee07d004709e1e51022e979/coderd/rbac/README.md#roles)_ | Role (example) | Site | Org | User | Result | |-----------------|------|------|------|--------| | non-org-member | \_ | N | YN\_ | N | | user | \_ | \_ | Y | Y | | WorkspaceBan | \_ | N | Y | Y | | unauthenticated | \_ | \_ | \_ | N | This new role, `WorkspaceCreationBan` is the same truth table condition as if the user was not a member of the organization (when doing a workspace create/delete). So this behavior **is not entirely new**.
How to do it without a negative permission The alternate approach would be to remove the implied permission, and grant it via and organization role. However this would add new behavior that an organizational role has the ability to grant a user permissions on their own resources? It does not make sense for an org role to prevent user from changing their profile information for example. So the only option is to create a new truth table column for resources that are owned by both an organization and a user. | Role (example) | Site | Org |User+Org| User | Result | |-----------------|------|------|--------|------|--------| | non-org-member | \_ | N | \_ | \_ | N | | user | \_ | \_ | \_ | \_ | N | | WorkspaceAllow | \_ | \_ | Y | \_ | Y | | unauthenticated | \_ | \_ | \_ | \_ | N | Now a user has no opinion on if they can create a workspace, which feels a little wrong. A user should have the authority over what is theres. There is fundamental _philosophical_ question of "Who does a workspace belong to?". The user has some set of autonomy, yet it is the organization that controls it's existence. A head scratcher :thinking:
## Will we need more negative built in roles? There are few resources that have shared ownership. Only `ResourceOrganizationMember` and `ResourceGroupMember`. Since negative permissions is intended to revoke access to a shared resource, then **no.** **This is the only one we need**. Classic resources like `ResourceTemplate` are entirely controlled by the Organization permissions. And resources entirely in the user control (like user profile) are only controlled by `User` permissions. ![Uploading Screenshot 2025-02-26 at 22.26.52.png…]() --------- Co-authored-by: Jaayden Halko Co-authored-by: ケイラ --- coderd/httpapi/httpapi.go | 10 +- coderd/rbac/roles.go | 107 ++++++++++++------ coderd/rbac/roles_test.go | 18 ++- coderd/workspaces_test.go | 48 ++++++++ coderd/wsbuilder/wsbuilder.go | 9 ++ codersdk/rbacroles.go | 11 +- enterprise/coderd/roles_test.go | 27 +++-- site/src/api/typesGenerated.ts | 4 + .../UserTable/EditRolesButton.stories.tsx | 12 ++ .../UserTable/EditRolesButton.tsx | 64 ++++++++++- site/src/testHelpers/entities.ts | 16 ++- 11 files changed, 261 insertions(+), 65 deletions(-) diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index a9687d58a0..d5895dcbf8 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -151,11 +151,13 @@ func ResourceNotFound(rw http.ResponseWriter) { Write(context.Background(), rw, http.StatusNotFound, ResourceNotFoundResponse) } +var ResourceForbiddenResponse = codersdk.Response{ + Message: "Forbidden.", + Detail: "You don't have permission to view this content. If you believe this is a mistake, please contact your administrator or try signing in with different credentials.", +} + func Forbidden(rw http.ResponseWriter) { - Write(context.Background(), rw, http.StatusForbidden, codersdk.Response{ - Message: "Forbidden.", - Detail: "You don't have permission to view this content. If you believe this is a mistake, please contact your administrator or try signing in with different credentials.", - }) + Write(context.Background(), rw, http.StatusForbidden, ResourceForbiddenResponse) } func InternalServerError(rw http.ResponseWriter, err error) { diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 7c73301643..440494450e 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -27,11 +27,12 @@ const ( customSiteRole string = "custom-site-role" customOrganizationRole string = "custom-organization-role" - orgAdmin string = "organization-admin" - orgMember string = "organization-member" - orgAuditor string = "organization-auditor" - orgUserAdmin string = "organization-user-admin" - orgTemplateAdmin string = "organization-template-admin" + orgAdmin string = "organization-admin" + orgMember string = "organization-member" + orgAuditor string = "organization-auditor" + orgUserAdmin string = "organization-user-admin" + orgTemplateAdmin string = "organization-template-admin" + orgWorkspaceCreationBan string = "organization-workspace-creation-ban" ) func init() { @@ -159,6 +160,10 @@ func RoleOrgTemplateAdmin() string { return orgTemplateAdmin } +func RoleOrgWorkspaceCreationBan() string { + return orgWorkspaceCreationBan +} + // ScopedRoleOrgAdmin is the org role with the organization ID func ScopedRoleOrgAdmin(organizationID uuid.UUID) RoleIdentifier { return RoleIdentifier{Name: RoleOrgAdmin(), OrganizationID: organizationID} @@ -181,6 +186,10 @@ func ScopedRoleOrgTemplateAdmin(organizationID uuid.UUID) RoleIdentifier { return RoleIdentifier{Name: RoleOrgTemplateAdmin(), OrganizationID: organizationID} } +func ScopedRoleOrgWorkspaceCreationBan(organizationID uuid.UUID) RoleIdentifier { + return RoleIdentifier{Name: RoleOrgWorkspaceCreationBan(), OrganizationID: organizationID} +} + func allPermsExcept(excepts ...Objecter) []Permission { resources := AllResources() var perms []Permission @@ -496,6 +505,31 @@ func ReloadBuiltinRoles(opts *RoleOptions) { User: []Permission{}, } }, + // orgWorkspaceCreationBan prevents creating & deleting workspaces. This + // overrides any permissions granted by the org or user level. It accomplishes + // this by using negative permissions. + orgWorkspaceCreationBan: func(organizationID uuid.UUID) Role { + return Role{ + Identifier: RoleIdentifier{Name: orgWorkspaceCreationBan, OrganizationID: organizationID}, + DisplayName: "Organization Workspace Creation Ban", + Site: []Permission{}, + Org: map[string][]Permission{ + organizationID.String(): { + { + Negate: true, + ResourceType: ResourceWorkspace.Type, + Action: policy.ActionCreate, + }, + { + Negate: true, + ResourceType: ResourceWorkspace.Type, + Action: policy.ActionDelete, + }, + }, + }, + User: []Permission{}, + } + }, } } @@ -506,44 +540,47 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // map[actor_role][assign_role] var assignRoles = map[string]map[string]bool{ "system": { - owner: true, - auditor: true, - member: true, - orgAdmin: true, - orgMember: true, - orgAuditor: true, - orgUserAdmin: true, - orgTemplateAdmin: true, - templateAdmin: true, - userAdmin: true, - customSiteRole: true, - customOrganizationRole: true, + owner: true, + auditor: true, + member: true, + orgAdmin: true, + orgMember: true, + orgAuditor: true, + orgUserAdmin: true, + orgTemplateAdmin: true, + orgWorkspaceCreationBan: true, + templateAdmin: true, + userAdmin: true, + customSiteRole: true, + customOrganizationRole: true, }, owner: { - owner: true, - auditor: true, - member: true, - orgAdmin: true, - orgMember: true, - orgAuditor: true, - orgUserAdmin: true, - orgTemplateAdmin: true, - templateAdmin: true, - userAdmin: true, - customSiteRole: true, - customOrganizationRole: true, + owner: true, + auditor: true, + member: true, + orgAdmin: true, + orgMember: true, + orgAuditor: true, + orgUserAdmin: true, + orgTemplateAdmin: true, + orgWorkspaceCreationBan: true, + templateAdmin: true, + userAdmin: true, + customSiteRole: true, + customOrganizationRole: true, }, userAdmin: { member: true, orgMember: true, }, orgAdmin: { - orgAdmin: true, - orgMember: true, - orgAuditor: true, - orgUserAdmin: true, - orgTemplateAdmin: true, - customOrganizationRole: true, + orgAdmin: true, + orgMember: true, + orgAuditor: true, + orgUserAdmin: true, + orgTemplateAdmin: true, + orgWorkspaceCreationBan: true, + customOrganizationRole: true, }, orgUserAdmin: { orgMember: true, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index b23849229e..f81d5723d5 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -112,6 +112,7 @@ func TestRolePermissions(t *testing.T) { // Subjects to user memberMe := authSubject{Name: "member_me", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember()}}} orgMemberMe := authSubject{Name: "org_member_me", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID)}}} + orgMemberMeBanWorkspace := authSubject{Name: "org_member_me_workspace_ban", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID), rbac.ScopedRoleOrgWorkspaceCreationBan(orgID)}}} groupMemberMe := authSubject{Name: "group_member_me", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID)}, Groups: []string{groupID.String()}}} owner := authSubject{Name: "owner", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleOwner()}}} @@ -181,20 +182,30 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionRead}, Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, orgMemberMe, orgAdmin, templateAdmin, orgTemplateAdmin}, + true: {owner, orgMemberMe, orgAdmin, templateAdmin, orgTemplateAdmin, orgMemberMeBanWorkspace}, false: {setOtherOrg, memberMe, userAdmin, orgAuditor, orgUserAdmin}, }, }, { - Name: "C_RDMyWorkspaceInOrg", + Name: "UpdateMyWorkspaceInOrg", // When creating the WithID won't be set, but it does not change the result. - Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + Actions: []policy.Action{policy.ActionUpdate}, Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgMemberMe, orgAdmin}, false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, }, }, + { + Name: "CreateDeleteMyWorkspaceInOrg", + // When creating the WithID won't be set, but it does not change the result. + Actions: []policy.Action{policy.ActionCreate, policy.ActionDelete}, + Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgMemberMe, orgAdmin}, + false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgMemberMeBanWorkspace}, + }, + }, { Name: "MyWorkspaceInOrgExecution", // When creating the WithID won't be set, but it does not change the result. @@ -942,6 +953,7 @@ func TestListRoles(t *testing.T) { fmt.Sprintf("organization-auditor:%s", orgID.String()), fmt.Sprintf("organization-user-admin:%s", orgID.String()), fmt.Sprintf("organization-template-admin:%s", orgID.String()), + fmt.Sprintf("organization-workspace-creation-ban:%s", orgID.String()), }, orgRoleNames) } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 7a81d51926..8ee23dcd51 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -375,6 +375,54 @@ func TestWorkspace(t *testing.T) { require.Error(t, err, "create workspace with archived version") require.ErrorContains(t, err, "Archived template versions cannot") }) + + t.Run("WorkspaceBan", func(t *testing.T) { + t.Parallel() + owner, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + first := coderdtest.CreateFirstUser(t, owner) + + version := coderdtest.CreateTemplateVersion(t, owner, first.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, owner, version.ID) + template := coderdtest.CreateTemplate(t, owner, first.OrganizationID, version.ID) + + goodClient, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID) + + // When a user with workspace-creation-ban + client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgWorkspaceCreationBan(first.OrganizationID)) + + // Ensure a similar user can create a workspace + coderdtest.CreateWorkspace(t, goodClient, template.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + // Then: Cannot create a workspace + _, err := client.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + TemplateVersionID: uuid.UUID{}, + Name: "random", + }) + require.Error(t, err) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + + // When: workspace-ban use has a workspace + wrk, err := owner.CreateUserWorkspace(ctx, user.ID.String(), codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + TemplateVersionID: uuid.UUID{}, + Name: "random", + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, wrk.LatestBuild.ID) + + // Then: They cannot delete said workspace + _, err = client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionDelete, + ProvisionerState: []byte{}, + }) + require.Error(t, err) + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + }) } func TestResolveAutostart(t *testing.T) { diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index a31e5eff46..f6d6d7381a 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -790,6 +790,15 @@ func (b *Builder) authorize(authFunc func(action policy.Action, object rbac.Obje return BuildError{http.StatusBadRequest, msg, xerrors.New(msg)} } if !authFunc(action, b.workspace) { + if authFunc(policy.ActionRead, b.workspace) { + // If the user can read the workspace, but not delete/create/update. Show + // a more helpful error. They are allowed to know the workspace exists. + return BuildError{ + Status: http.StatusForbidden, + Message: fmt.Sprintf("You do not have permission to %s this workspace.", action), + Wrapped: xerrors.New(httpapi.ResourceForbiddenResponse.Detail), + } + } // We use the same wording as the httpapi to avoid leaking the existence of the workspace return BuildError{http.StatusNotFound, httpapi.ResourceNotFoundResponse.Message, xerrors.New(httpapi.ResourceNotFoundResponse.Message)} } diff --git a/codersdk/rbacroles.go b/codersdk/rbacroles.go index 49ed5c5b73..7721eacbd5 100644 --- a/codersdk/rbacroles.go +++ b/codersdk/rbacroles.go @@ -8,9 +8,10 @@ const ( RoleUserAdmin string = "user-admin" RoleAuditor string = "auditor" - RoleOrganizationAdmin string = "organization-admin" - RoleOrganizationMember string = "organization-member" - RoleOrganizationAuditor string = "organization-auditor" - RoleOrganizationTemplateAdmin string = "organization-template-admin" - RoleOrganizationUserAdmin string = "organization-user-admin" + RoleOrganizationAdmin string = "organization-admin" + RoleOrganizationMember string = "organization-member" + RoleOrganizationAuditor string = "organization-auditor" + RoleOrganizationTemplateAdmin string = "organization-template-admin" + RoleOrganizationUserAdmin string = "organization-user-admin" + RoleOrganizationWorkspaceCreationBan string = "organization-workspace-creation-ban" ) diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 8bbf921805..57b66a3682 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -441,10 +441,11 @@ func TestListRoles(t *testing.T) { return member.ListOrganizationRoles(ctx, owner.OrganizationID) }, ExpectedRoles: convertRoles(map[rbac.RoleIdentifier]bool{ - {Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: false, - {Name: codersdk.RoleOrganizationAuditor, OrganizationID: owner.OrganizationID}: false, - {Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: false, - {Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: false, + {Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: false, + {Name: codersdk.RoleOrganizationAuditor, OrganizationID: owner.OrganizationID}: false, + {Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: false, + {Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: false, + {Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: false, }), }, { @@ -473,10 +474,11 @@ func TestListRoles(t *testing.T) { return orgAdmin.ListOrganizationRoles(ctx, owner.OrganizationID) }, ExpectedRoles: convertRoles(map[rbac.RoleIdentifier]bool{ - {Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: true, - {Name: codersdk.RoleOrganizationAuditor, OrganizationID: owner.OrganizationID}: true, - {Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: true, - {Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true, + {Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: true, + {Name: codersdk.RoleOrganizationAuditor, OrganizationID: owner.OrganizationID}: true, + {Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: true, + {Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true, + {Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: true, }), }, { @@ -505,10 +507,11 @@ func TestListRoles(t *testing.T) { return client.ListOrganizationRoles(ctx, owner.OrganizationID) }, ExpectedRoles: convertRoles(map[rbac.RoleIdentifier]bool{ - {Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: true, - {Name: codersdk.RoleOrganizationAuditor, OrganizationID: owner.OrganizationID}: true, - {Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: true, - {Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true, + {Name: codersdk.RoleOrganizationAdmin, OrganizationID: owner.OrganizationID}: true, + {Name: codersdk.RoleOrganizationAuditor, OrganizationID: owner.OrganizationID}: true, + {Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: true, + {Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true, + {Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: true, }), }, } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fdda122540..1a011b57b4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2101,6 +2101,10 @@ export const RoleOrganizationTemplateAdmin = "organization-template-admin"; // From codersdk/rbacroles.go export const RoleOrganizationUserAdmin = "organization-user-admin"; +// From codersdk/rbacroles.go +export const RoleOrganizationWorkspaceCreationBan = + "organization-workspace-creation-ban"; + // From codersdk/rbacroles.go export const RoleOwner = "owner"; diff --git a/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.stories.tsx b/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.stories.tsx index 0511a9d877..f324489848 100644 --- a/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.stories.tsx @@ -4,6 +4,7 @@ import { MockOwnerRole, MockSiteRoles, MockUserAdminRole, + MockWorkspaceCreationBanRole, } from "testHelpers/entities"; import { withDesktopViewport } from "testHelpers/storybook"; import { EditRolesButton } from "./EditRolesButton"; @@ -41,3 +42,14 @@ export const Loading: Story = { await userEvent.click(canvas.getByRole("button")); }, }; + +export const AdvancedOpen: Story = { + args: { + selectedRoleNames: new Set([MockWorkspaceCreationBanRole.name]), + roles: MockSiteRoles, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.tsx b/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.tsx index 64e059b413..c8eb4001e4 100644 --- a/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.tsx +++ b/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.tsx @@ -16,7 +16,9 @@ import { PopoverContent, PopoverTrigger, } from "components/deprecated/Popover/Popover"; -import type { FC } from "react"; +import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; +import { type FC, useEffect, useState } from "react"; +import { cn } from "utils/cn"; const roleDescriptions: Record = { owner: @@ -57,7 +59,7 @@ const Option: FC = ({ }} />
- {name} + {name} {description}
@@ -91,6 +93,7 @@ export const EditRolesButton: FC = ({ onChange([...selectedRoleNames, roleName]); }; + const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const canSetRoles = userLoginType !== "oidc" || (userLoginType === "oidc" && !oidcRoleSync); @@ -109,6 +112,20 @@ export const EditRolesButton: FC = ({ ); } + const filteredRoles = roles.filter( + (role) => role.name !== "organization-workspace-creation-ban", + ); + const advancedRoles = roles.filter( + (role) => role.name === "organization-workspace-creation-ban", + ); + + // make sure the advanced roles are always visible if the user has one of these roles + useEffect(() => { + if (selectedRoleNames.has("organization-workspace-creation-ban")) { + setIsAdvancedOpen(true); + } + }, [selectedRoleNames]); + return ( @@ -124,14 +141,14 @@ export const EditRolesButton: FC = ({ - +
-
- {roles.map((role) => ( +
+ {filteredRoles.map((role) => (
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 938537c08d..12654bc064 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -296,6 +296,15 @@ export const MockAuditorRole: TypesGen.Role = { organization_id: "", }; +export const MockWorkspaceCreationBanRole: TypesGen.Role = { + name: "organization-workspace-creation-ban", + display_name: "Organization Workspace Creation Ban", + site_permissions: [], + organization_permissions: [], + user_permissions: [], + organization_id: "", +}; + export const MockMemberRole: TypesGen.SlimRole = { name: "member", display_name: "Member", @@ -459,10 +468,15 @@ export function assignableRole( }; } -export const MockSiteRoles = [MockUserAdminRole, MockAuditorRole]; +export const MockSiteRoles = [ + MockUserAdminRole, + MockAuditorRole, + MockWorkspaceCreationBanRole, +]; export const MockAssignableSiteRoles = [ assignableRole(MockUserAdminRole, true), assignableRole(MockAuditorRole, true), + assignableRole(MockWorkspaceCreationBanRole, true), ]; export const MockMemberPermissions = {