Skip to content

feat: implement WorkspaceCreationBan org role (cherry-pick #16686) #16786

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions coderd/httpapi/httpapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
107 changes: 72 additions & 35 deletions coderd/rbac/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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}
Expand All @@ -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
Expand Down Expand Up @@ -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{},
}
},
}
}

Expand All @@ -506,44 +540,47 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// map[actor_role][assign_role]<can_assign>
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,
Expand Down
18 changes: 15 additions & 3 deletions coderd/rbac/roles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()}}}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down
48 changes: 48 additions & 0 deletions coderd/workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions coderd/wsbuilder/wsbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
}
Expand Down
11 changes: 6 additions & 5 deletions codersdk/rbacroles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
27 changes: 15 additions & 12 deletions enterprise/coderd/roles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
},
{
Expand Down Expand Up @@ -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,
}),
},
{
Expand Down Expand Up @@ -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,
}),
},
}
Expand Down
4 changes: 4 additions & 0 deletions site/src/api/typesGenerated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
MockOwnerRole,
MockSiteRoles,
MockUserAdminRole,
MockWorkspaceCreationBanRole,
} from "testHelpers/entities";
import { withDesktopViewport } from "testHelpers/storybook";
import { EditRolesButton } from "./EditRolesButton";
Expand Down Expand Up @@ -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"));
},
};
Loading
Loading