chore: implement deleting custom roles (#14101)

* chore: implement deleting custom roles

* add trigger to delete role from organization members on delete
* chore: add comments to explain populated field
This commit is contained in:
Steven Masley
2024-08-07 12:37:55 -05:00
committed by GitHub
parent d0feb70811
commit 2c13797350
19 changed files with 627 additions and 2 deletions
+45
View File
@@ -2549,6 +2549,51 @@ const docTemplate = `{
}
}
},
"/organizations/{organization}/members/roles/{roleName}": {
"delete": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Delete a custom organization role",
"operationId": "delete-a-custom-organization-role",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Role name",
"name": "roleName",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Role"
}
}
}
}
}
},
"/organizations/{organization}/members/{user}": {
"post": {
"security": [
+41
View File
@@ -2227,6 +2227,47 @@
}
}
},
"/organizations/{organization}/members/roles/{roleName}": {
"delete": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Members"],
"summary": "Delete a custom organization role",
"operationId": "delete-a-custom-organization-role",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Role name",
"name": "roleName",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Role"
}
}
}
}
}
},
"/organizations/{organization}/members/{user}": {
"post": {
"security": [
+14
View File
@@ -958,6 +958,20 @@ func (q *querier) DeleteCoordinator(ctx context.Context, id uuid.UUID) error {
return q.db.DeleteCoordinator(ctx, id)
}
func (q *querier) DeleteCustomRole(ctx context.Context, arg database.DeleteCustomRoleParams) error {
if arg.OrganizationID.UUID != uuid.Nil {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAssignOrgRole.InOrg(arg.OrganizationID.UUID)); err != nil {
return err
}
} else {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAssignRole); err != nil {
return err
}
}
return q.db.DeleteCustomRole(ctx, arg)
}
func (q *querier) DeleteExternalAuthLink(ctx context.Context, arg database.DeleteExternalAuthLinkParams) error {
return fetchAndExec(q.log, q.auth, policy.ActionUpdatePersonal, func(ctx context.Context, arg database.DeleteExternalAuthLinkParams) (database.ExternalAuthLink, error) {
//nolint:gosimple
+25
View File
@@ -1247,6 +1247,31 @@ func (s *MethodTestSuite) TestUser() {
s.Run("CustomRoles", s.Subtest(func(db database.Store, check *expects) {
check.Args(database.CustomRolesParams{}).Asserts(rbac.ResourceAssignRole, policy.ActionRead).Returns([]database.CustomRole{})
}))
s.Run("Organization/DeleteCustomRole", s.Subtest(func(db database.Store, check *expects) {
customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{
OrganizationID: uuid.NullUUID{
UUID: uuid.New(),
Valid: true,
},
})
check.Args(database.DeleteCustomRoleParams{
Name: customRole.Name,
OrganizationID: customRole.OrganizationID,
}).Asserts(
rbac.ResourceAssignOrgRole.InOrg(customRole.OrganizationID.UUID), policy.ActionDelete)
}))
s.Run("Site/DeleteCustomRole", s.Subtest(func(db database.Store, check *expects) {
customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{
OrganizationID: uuid.NullUUID{
UUID: uuid.Nil,
Valid: false,
},
})
check.Args(database.DeleteCustomRoleParams{
Name: customRole.Name,
}).Asserts(
rbac.ResourceAssignRole, policy.ActionDelete)
}))
s.Run("Blank/UpsertCustomRole", s.Subtest(func(db database.Store, check *expects) {
// Blank is no perms in the role
check.Args(database.UpsertCustomRoleParams{
+29
View File
@@ -1381,6 +1381,35 @@ func (*FakeQuerier) DeleteCoordinator(context.Context, uuid.UUID) error {
return ErrUnimplemented
}
func (q *FakeQuerier) DeleteCustomRole(_ context.Context, arg database.DeleteCustomRoleParams) error {
err := validateDatabaseType(arg)
if err != nil {
return err
}
q.mutex.RLock()
defer q.mutex.RUnlock()
initial := len(q.data.customRoles)
q.data.customRoles = slices.DeleteFunc(q.data.customRoles, func(role database.CustomRole) bool {
return role.OrganizationID.UUID == arg.OrganizationID.UUID && role.Name == arg.Name
})
if initial == len(q.data.customRoles) {
return sql.ErrNoRows
}
// Emulate the trigger 'remove_organization_member_custom_role'
for i, mem := range q.organizationMembers {
if mem.OrganizationID == arg.OrganizationID.UUID {
mem.Roles = slices.DeleteFunc(mem.Roles, func(role string) bool {
return role == arg.Name
})
q.organizationMembers[i] = mem
}
}
return nil
}
func (q *FakeQuerier) DeleteExternalAuthLink(_ context.Context, arg database.DeleteExternalAuthLinkParams) error {
err := validateDatabaseType(arg)
if err != nil {
+7
View File
@@ -214,6 +214,13 @@ func (m metricsStore) DeleteCoordinator(ctx context.Context, id uuid.UUID) error
return r0
}
func (m metricsStore) DeleteCustomRole(ctx context.Context, arg database.DeleteCustomRoleParams) error {
start := time.Now()
r0 := m.s.DeleteCustomRole(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteCustomRole").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) DeleteExternalAuthLink(ctx context.Context, arg database.DeleteExternalAuthLinkParams) error {
start := time.Now()
r0 := m.s.DeleteExternalAuthLink(ctx, arg)
+14
View File
@@ -317,6 +317,20 @@ func (mr *MockStoreMockRecorder) DeleteCoordinator(arg0, arg1 any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCoordinator", reflect.TypeOf((*MockStore)(nil).DeleteCoordinator), arg0, arg1)
}
// DeleteCustomRole mocks base method.
func (m *MockStore) DeleteCustomRole(arg0 context.Context, arg1 database.DeleteCustomRoleParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteCustomRole", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteCustomRole indicates an expected call of DeleteCustomRole.
func (mr *MockStoreMockRecorder) DeleteCustomRole(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCustomRole", reflect.TypeOf((*MockStore)(nil).DeleteCustomRole), arg0, arg1)
}
// DeleteExternalAuthLink mocks base method.
func (m *MockStore) DeleteExternalAuthLink(arg0 context.Context, arg1 database.DeleteExternalAuthLinkParams) error {
m.ctrl.T.Helper()
+24
View File
@@ -302,6 +302,26 @@ BEGIN
END;
$$;
CREATE FUNCTION remove_organization_member_role() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
-- Delete the role from all organization members that have it.
-- TODO: When site wide custom roles are supported, if the
-- organization_id is null, we should remove the role from the 'users'
-- table instead.
IF OLD.organization_id IS NOT NULL THEN
UPDATE organization_members
-- this is a noop if the role is not assigned to the member
SET roles = array_remove(roles, OLD.name)
WHERE
-- Scope to the correct organization
organization_members.organization_id = OLD.organization_id;
END IF;
RETURN OLD;
END;
$$;
CREATE FUNCTION tailnet_notify_agent_change() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -1838,6 +1858,10 @@ CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (own
CREATE TRIGGER inhibit_enqueue_if_disabled BEFORE INSERT ON notification_messages FOR EACH ROW EXECUTE FUNCTION inhibit_enqueue_if_disabled();
CREATE TRIGGER remove_organization_member_custom_role BEFORE DELETE ON custom_roles FOR EACH ROW EXECUTE FUNCTION remove_organization_member_role();
COMMENT ON TRIGGER remove_organization_member_custom_role ON custom_roles IS 'When a custom_role is deleted, this trigger removes the role from all organization members.';
CREATE TRIGGER tailnet_notify_agent_change AFTER INSERT OR DELETE OR UPDATE ON tailnet_agents FOR EACH ROW EXECUTE FUNCTION tailnet_notify_agent_change();
CREATE TRIGGER tailnet_notify_client_change AFTER INSERT OR DELETE OR UPDATE ON tailnet_clients FOR EACH ROW EXECUTE FUNCTION tailnet_notify_client_change();
@@ -0,0 +1,2 @@
DROP TRIGGER IF EXISTS remove_organization_member_custom_role ON custom_roles;
DROP FUNCTION IF EXISTS remove_organization_member_role;
@@ -0,0 +1,35 @@
-- When a custom role is deleted, we need to remove the assigned role
-- from all organization members that have it.
-- This action cannot be reverted, so deleting a custom role should be
-- done with caution.
CREATE OR REPLACE FUNCTION remove_organization_member_role()
RETURNS TRIGGER AS
$$
BEGIN
-- Delete the role from all organization members that have it.
-- TODO: When site wide custom roles are supported, if the
-- organization_id is null, we should remove the role from the 'users'
-- table instead.
IF OLD.organization_id IS NOT NULL THEN
UPDATE organization_members
-- this is a noop if the role is not assigned to the member
SET roles = array_remove(roles, OLD.name)
WHERE
-- Scope to the correct organization
organization_members.organization_id = OLD.organization_id;
END IF;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
-- Attach the function to deleting the custom role
CREATE TRIGGER remove_organization_member_custom_role
BEFORE DELETE ON custom_roles FOR EACH ROW
EXECUTE PROCEDURE remove_organization_member_role();
COMMENT ON TRIGGER
remove_organization_member_custom_role
ON custom_roles IS
'When a custom_role is deleted, this trigger removes the role from all organization members.';
+1
View File
@@ -69,6 +69,7 @@ type sqlcQuerier interface {
DeleteAllTailnetTunnels(ctx context.Context, arg DeleteAllTailnetTunnelsParams) error
DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error
DeleteCoordinator(ctx context.Context, id uuid.UUID) error
DeleteCustomRole(ctx context.Context, arg DeleteCustomRoleParams) error
DeleteExternalAuthLink(ctx context.Context, arg DeleteExternalAuthLinkParams) error
DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error
DeleteGroupByID(ctx context.Context, id uuid.UUID) error
+18
View File
@@ -6520,6 +6520,24 @@ func (q *sqlQuerier) CustomRoles(ctx context.Context, arg CustomRolesParams) ([]
return items, nil
}
const deleteCustomRole = `-- name: DeleteCustomRole :exec
DELETE FROM
custom_roles
WHERE
name = lower($1)
AND organization_id = $2
`
type DeleteCustomRoleParams struct {
Name string `db:"name" json:"name"`
OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
}
func (q *sqlQuerier) DeleteCustomRole(ctx context.Context, arg DeleteCustomRoleParams) error {
_, err := q.db.ExecContext(ctx, deleteCustomRole, arg.Name, arg.OrganizationID)
return err
}
const upsertCustomRole = `-- name: UpsertCustomRole :one
INSERT INTO
custom_roles (
+7
View File
@@ -25,6 +25,13 @@ WHERE
END
;
-- name: DeleteCustomRole :exec
DELETE FROM
custom_roles
WHERE
name = lower(@name)
AND organization_id = @organization_id
;
-- name: UpsertCustomRole :one
INSERT INTO
+13 -1
View File
@@ -106,12 +106,24 @@ func Is404Error(err error) bool {
return false
}
// This tests for dbauthz.IsNotAuthorizedError and rbac.IsUnauthorizedError.
if IsUnauthorizedError(err) {
return true
}
return xerrors.Is(err, sql.ErrNoRows)
}
func IsUnauthorizedError(err error) bool {
if err == nil {
return false
}
// This tests for dbauthz.IsNotAuthorizedError and rbac.IsUnauthorizedError.
var unauthorized httpapiconstraints.IsUnauthorizedError
if errors.As(err, &unauthorized) && unauthorized.IsUnauthorized() {
return true
}
return xerrors.Is(err, sql.ErrNoRows)
return false
}
// Convenience error functions don't take contexts since their responses are
+14
View File
@@ -105,6 +105,20 @@ func (c *Client) PatchOrganizationRole(ctx context.Context, role Role) (Role, er
return r, json.NewDecoder(res.Body).Decode(&r)
}
// DeleteOrganizationRole will delete a custom organization role
func (c *Client) DeleteOrganizationRole(ctx context.Context, organizationID uuid.UUID, roleName string) error {
res, err := c.Request(ctx, http.MethodDelete,
fmt.Sprintf("/api/v2/organizations/%s/members/roles/%s", organizationID.String(), roleName), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// ListSiteRoles lists all assignable site wide roles.
func (c *Client) ListSiteRoles(ctx context.Context) ([]AssignableRoles, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/roles", nil)
+126
View File
@@ -367,6 +367,132 @@ Status Code **200**
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Delete a custom organization role
### Code samples
```shell
# Example request using curl
curl -X DELETE http://coder-server:8080/api/v2/organizations/{organization}/members/roles/{roleName} \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`DELETE /organizations/{organization}/members/roles/{roleName}`
### Parameters
| Name | In | Type | Required | Description |
| -------------- | ---- | ------------ | -------- | --------------- |
| `organization` | path | string(uuid) | true | Organization ID |
| `roleName` | path | string | true | Role name |
### Example responses
> 200 Response
```json
[
{
"display_name": "string",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"organization_permissions": [
{
"action": "application_connect",
"negate": true,
"resource_type": "*"
}
],
"site_permissions": [
{
"action": "application_connect",
"negate": true,
"resource_type": "*"
}
],
"user_permissions": [
{
"action": "application_connect",
"negate": true,
"resource_type": "*"
}
]
}
]
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------- |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Role](schemas.md#codersdkrole) |
<h3 id="delete-a-custom-organization-role-responseschema">Response Schema</h3>
Status Code **200**
| Name | Type | Required | Restrictions | Description |
| ---------------------------- | -------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------- |
| `[array item]` | array | false | | |
| `» display_name` | string | false | | |
| `» name` | string | false | | |
| `» organization_id` | string(uuid) | false | | |
| `» organization_permissions` | array | false | | Organization permissions are specific for the organization in the field 'OrganizationID' above. |
| `»» action` | [codersdk.RBACAction](schemas.md#codersdkrbacaction) | false | | |
| `»» negate` | boolean | false | | Negate makes this a negative permission |
| `»» resource_type` | [codersdk.RBACResource](schemas.md#codersdkrbacresource) | false | | |
| `» site_permissions` | array | false | | |
| `» user_permissions` | array | false | | |
#### Enumerated Values
| Property | Value |
| --------------- | ------------------------- |
| `action` | `application_connect` |
| `action` | `assign` |
| `action` | `create` |
| `action` | `delete` |
| `action` | `read` |
| `action` | `read_personal` |
| `action` | `ssh` |
| `action` | `update` |
| `action` | `update_personal` |
| `action` | `use` |
| `action` | `view_insights` |
| `action` | `start` |
| `action` | `stop` |
| `resource_type` | `*` |
| `resource_type` | `api_key` |
| `resource_type` | `assign_org_role` |
| `resource_type` | `assign_role` |
| `resource_type` | `audit_log` |
| `resource_type` | `debug_info` |
| `resource_type` | `deployment_config` |
| `resource_type` | `deployment_stats` |
| `resource_type` | `file` |
| `resource_type` | `group` |
| `resource_type` | `license` |
| `resource_type` | `notification_preference` |
| `resource_type` | `notification_template` |
| `resource_type` | `oauth2_app` |
| `resource_type` | `oauth2_app_code_token` |
| `resource_type` | `oauth2_app_secret` |
| `resource_type` | `organization` |
| `resource_type` | `organization_member` |
| `resource_type` | `provisioner_daemon` |
| `resource_type` | `provisioner_keys` |
| `resource_type` | `replicas` |
| `resource_type` | `system` |
| `resource_type` | `tailnet_coordinator` |
| `resource_type` | `template` |
| `resource_type` | `user` |
| `resource_type` | `workspace` |
| `resource_type` | `workspace_dormant` |
| `resource_type` | `workspace_proxy` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Add organization member
### Code samples
+1
View File
@@ -269,6 +269,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
httpmw.ExtractOrganizationParam(api.Database),
)
r.Patch("/organizations/{organization}/members/roles", api.patchOrgRoles)
r.Delete("/organizations/{organization}/members/roles/{roleName}", api.deleteOrgRole)
})
r.Route("/organizations/{organization}/groups", func(r chi.Router) {
+83 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/coder/coder/v2/coderd/audit"
@@ -92,7 +93,8 @@ func (api *API) patchOrgRoles(rw http.ResponseWriter, r *http.Request) {
},
},
ExcludeOrgRoles: false,
OrganizationID: organization.ID,
// Linter requires all fields to be set. This field is not actually required.
OrganizationID: organization.ID,
})
// If it is a 404 (not found) error, ignore it.
if err != nil && !httpapi.Is404Error(err) {
@@ -131,6 +133,86 @@ func (api *API) patchOrgRoles(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Role(inserted))
}
// deleteOrgRole will remove a custom role from an organization
//
// @Summary Delete a custom organization role
// @ID delete-a-custom-organization-role
// @Security CoderSessionToken
// @Produce json
// @Param organization path string true "Organization ID" format(uuid)
// @Param roleName path string true "Role name"
// @Tags Members
// @Success 200 {array} codersdk.Role
// @Router /organizations/{organization}/members/roles/{roleName} [delete]
func (api *API) deleteOrgRole(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
auditor = api.AGPL.Auditor.Load()
organization = httpmw.OrganizationParam(r)
aReq, commitAudit = audit.InitRequest[database.CustomRole](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionDelete,
OrganizationID: organization.ID,
})
)
defer commitAudit()
rolename := chi.URLParam(r, "roleName")
roles, err := api.Database.CustomRoles(ctx, database.CustomRolesParams{
LookupRoles: []database.NameOrganizationPair{
{
Name: rolename,
OrganizationID: organization.ID,
},
},
ExcludeOrgRoles: false,
// Linter requires all fields to be set. This field is not actually required.
OrganizationID: organization.ID,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
if len(roles) == 0 {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: fmt.Sprintf("No custom role with the name %s found", rolename),
Detail: "no role found",
Validations: nil,
})
return
}
if len(roles) > 1 {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("Multiple roles with the name %s found", rolename),
Detail: "multiple roles found, this should never happen",
Validations: nil,
})
return
}
aReq.Old = roles[0]
err = api.Database.DeleteCustomRole(ctx, database.DeleteCustomRoleParams{
Name: rolename,
OrganizationID: uuid.NullUUID{
UUID: organization.ID,
Valid: true,
},
})
if httpapi.IsUnauthorizedError(err) {
httpapi.Forbidden(rw)
return
}
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
aReq.New = database.CustomRole{}
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}
func sdkPermissionToDB(p codersdk.Permission) database.CustomRolePermission {
return database.CustomRolePermission{
Negate: p.Negate,
+128
View File
@@ -310,6 +310,134 @@ func TestCustomOrganizationRole(t *testing.T) {
_, err := owner.PatchOrganizationRole(ctx, newRole)
require.ErrorContains(t, err, "Resource not found")
})
t.Run("Delete", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)}
owner, first := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureCustomRoles: 1,
},
},
})
orgAdmin, orgAdminUser := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID))
ctx := testutil.Context(t, testutil.WaitMedium)
createdRole, err := orgAdmin.PatchOrganizationRole(ctx, templateAdminCustom(first.OrganizationID))
require.NoError(t, err, "upsert role")
//nolint:gocritic // org_admin cannot assign to themselves
_, err = owner.UpdateOrganizationMemberRoles(ctx, first.OrganizationID, orgAdminUser.ID.String(), codersdk.UpdateRoles{
// Give the user this custom role, to ensure when it is deleted, the user
// is ok to be used.
Roles: []string{createdRole.Name, rbac.ScopedRoleOrgAdmin(first.OrganizationID).Name},
})
require.NoError(t, err, "assign custom role to user")
existingRoles, err := orgAdmin.ListOrganizationRoles(ctx, first.OrganizationID)
require.NoError(t, err)
exists := slices.ContainsFunc(existingRoles, func(role codersdk.AssignableRoles) bool {
return role.Name == createdRole.Name
})
require.True(t, exists, "custom role should exist")
// Delete the role
err = orgAdmin.DeleteOrganizationRole(ctx, first.OrganizationID, createdRole.Name)
require.NoError(t, err)
existingRoles, err = orgAdmin.ListOrganizationRoles(ctx, first.OrganizationID)
require.NoError(t, err)
exists = slices.ContainsFunc(existingRoles, func(role codersdk.AssignableRoles) bool {
return role.Name == createdRole.Name
})
require.False(t, exists, "custom role should be deleted")
// Verify you can still assign roles.
// There used to be a bug that if a member had a delete role, they
// could not be assigned roles anymore.
//nolint:gocritic // org_admin cannot assign to themselves
_, err = owner.UpdateOrganizationMemberRoles(ctx, first.OrganizationID, orgAdminUser.ID.String(), codersdk.UpdateRoles{
Roles: []string{rbac.ScopedRoleOrgAdmin(first.OrganizationID).Name},
})
require.NoError(t, err)
})
// Verify deleting a custom role cascades to all members
t.Run("DeleteRoleCascadeMembers", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)}
owner, first := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureCustomRoles: 1,
},
},
})
orgAdmin, orgAdminUser := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID))
ctx := testutil.Context(t, testutil.WaitMedium)
createdRole, err := orgAdmin.PatchOrganizationRole(ctx, templateAdminCustom(first.OrganizationID))
require.NoError(t, err, "upsert role")
customRoleIdentifier := rbac.RoleIdentifier{
Name: createdRole.Name,
OrganizationID: first.OrganizationID,
}
// Create a few members with the role
coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, customRoleIdentifier)
coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID), customRoleIdentifier)
coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(first.OrganizationID), rbac.ScopedRoleOrgAuditor(first.OrganizationID), customRoleIdentifier)
// Verify members have the custom role
originalMembers, err := orgAdmin.OrganizationMembers(ctx, first.OrganizationID)
require.NoError(t, err)
require.Len(t, originalMembers, 5) // 3 members + org admin + owner
for _, member := range originalMembers {
if member.UserID == orgAdminUser.ID || member.UserID == first.UserID {
continue
}
require.True(t, slices.ContainsFunc(member.Roles, func(role codersdk.SlimRole) bool {
return role.Name == customRoleIdentifier.Name
}), "member should have custom role")
}
err = orgAdmin.DeleteOrganizationRole(ctx, first.OrganizationID, createdRole.Name)
require.NoError(t, err)
// Verify the role was removed from all members
members, err := orgAdmin.OrganizationMembers(ctx, first.OrganizationID)
require.NoError(t, err)
require.Len(t, members, 5) // 3 members + org admin + owner
for _, member := range members {
require.False(t, slices.ContainsFunc(member.Roles, func(role codersdk.SlimRole) bool {
return role.Name == customRoleIdentifier.Name
}), "role should be removed from all users")
// Verify the rest of the member's roles are unchanged
original := originalMembers[slices.IndexFunc(originalMembers, func(haystack codersdk.OrganizationMemberWithUserData) bool {
return haystack.UserID == member.UserID
})]
originalWithoutCustom := slices.DeleteFunc(original.Roles, func(role codersdk.SlimRole) bool {
return role.Name == customRoleIdentifier.Name
})
require.ElementsMatch(t, originalWithoutCustom, member.Roles, "original roles are unchanged")
}
})
}
func TestListRoles(t *testing.T) {