diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index a9687d58a0604..d5895dcbf86f0 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 7c733016430fe..440494450e2d1 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 b23849229e900..f81d5723d5ec2 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 7a81d5192668f..8ee23dcd5100d 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 a31e5eff4686a..f6d6d7381a24f 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 49ed5c5b73176..7721eacbd5624 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 8bbf9218058e7..57b66a368248c 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 fdda12254052c..1a011b57b4c39 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 0511a9d877ea1..f3244898483ce 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 64e059b4134f6..c8eb4001e406a 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 938537c08d70c..12654bc064fee 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 = { pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy