feat: add api for patching custom org roles (#13357)

* chore: implement patching custom organization roles
This commit is contained in:
Steven Masley
2024-05-29 09:49:43 -05:00
committed by GitHub
parent b69f6358f0
commit afd9d3b35f
16 changed files with 592 additions and 491 deletions

View File

@ -327,22 +327,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
})
})
r.Route("/users/roles", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
)
r.Group(func(r chi.Router) {
r.Use(
api.customRolesEnabledMW,
)
r.Patch("/", api.patchRole)
})
// Unfortunate, but this r.Route overrides the AGPL roles route.
// The AGPL does not have the entitlements to block the licensed
// routes, so we need to duplicate the AGPL here.
r.Get("/", api.AGPL.AssignableSiteRoles)
})
r.Route("/users/{user}/quiet-hours", func(r chi.Router) {
r.Use(
api.autostopRequirementEnabledMW,
@ -761,6 +745,11 @@ func (api *API) updateEntitlements(ctx context.Context) error {
api.AGPL.PortSharer.Store(&ps)
}
if initial, changed, enabled := featureChanged(codersdk.FeatureCustomRoles); shouldUpdate(initial, changed, enabled) {
var handler coderd.CustomRoleHandler = &enterpriseCustomRoleHandler{Enabled: enabled}
api.AGPL.CustomRoleHandler.Store(&handler)
}
// External token encryption is soft-enforced
featureExternalTokenEncryption := entitlements.Features[codersdk.FeatureExternalTokenEncryption]
featureExternalTokenEncryption.Enabled = len(api.ExternalTokenEncryption) > 0

View File

@ -1,6 +1,8 @@
package coderd
import (
"context"
"fmt"
"net/http"
"github.com/google/uuid"
@ -12,70 +14,83 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// patchRole will allow creating a custom role
//
// @Summary Upsert a custom site-wide role
// @ID upsert-a-custom-site-wide-role
// @Security CoderSessionToken
// @Produce json
// @Tags Members
// @Success 200 {array} codersdk.Role
// @Router /users/roles [patch]
func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
type enterpriseCustomRoleHandler struct {
Enabled bool
}
var req codersdk.Role
if !httpapi.Read(ctx, rw, r, &req) {
return
func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context, db database.Store, rw http.ResponseWriter, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool) {
if !h.Enabled {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Custom roles are not enabled",
})
return codersdk.Role{}, false
}
if err := httpapi.NameValid(req.Name); err != nil {
if err := httpapi.NameValid(role.Name); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid role name",
Detail: err.Error(),
})
return
return codersdk.Role{}, false
}
if len(req.OrganizationPermissions) > 0 {
// Org perms should be assigned only in org specific roles. Otherwise,
// it gets complicated to keep track of who can do what.
// Only organization permissions are allowed to be granted
if len(role.SitePermissions) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid request, not allowed to assign organization permissions for a site wide role.",
Detail: "site wide roles may not contain organization specific permissions",
Message: "Invalid request, not allowed to assign site wide permissions for an organization role.",
Detail: "organization scoped roles may not contain site wide permissions",
})
return
return codersdk.Role{}, false
}
if len(role.UserPermissions) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid request, not allowed to assign user permissions for an organization role.",
Detail: "organization scoped roles may not contain user permissions",
})
return codersdk.Role{}, false
}
if role.OrganizationID != orgID.String() {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid request, organization in role and url must match",
Detail: fmt.Sprintf("role organization=%q does not match URL=%q", role.OrganizationID, orgID.String()),
})
return codersdk.Role{}, false
}
// Make sure all permissions inputted are valid according to our policy.
rbacRole := db2sdk.RoleToRBAC(req)
rbacRole := db2sdk.RoleToRBAC(role)
args, err := rolestore.ConvertRoleToDB(rbacRole)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid request",
Detail: err.Error(),
})
return
return codersdk.Role{}, false
}
inserted, err := api.Database.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{
Name: args.Name,
DisplayName: args.DisplayName,
OrganizationID: uuid.NullUUID{},
inserted, err := db.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{
Name: args.Name,
DisplayName: args.DisplayName,
OrganizationID: uuid.NullUUID{
UUID: orgID,
Valid: true,
},
SitePermissions: args.SitePermissions,
OrgPermissions: args.OrgPermissions,
UserPermissions: args.UserPermissions,
})
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
return codersdk.Role{}, false
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to update role permissions",
Detail: err.Error(),
})
return
return codersdk.Role{}, false
}
convertedInsert, err := rolestore.ConvertDBRole(inserted)
@ -84,8 +99,8 @@ func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) {
Message: "Permissions were updated, unable to read them back out of the database.",
Detail: err.Error(),
})
return
return codersdk.Role{}, false
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Role(convertedInsert))
return db2sdk.Role(convertedInsert), true
}

View File

@ -5,10 +5,10 @@ import (
"slices"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@ -16,19 +16,22 @@ import (
"github.com/coder/coder/v2/testutil"
)
func TestCustomRole(t *testing.T) {
func TestCustomOrganizationRole(t *testing.T) {
t.Parallel()
templateAdminCustom := codersdk.Role{
Name: "test-role",
DisplayName: "Testing Purposes",
// Basically creating a template admin manually
SitePermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionViewInsights},
codersdk.ResourceFile: {codersdk.ActionCreate, codersdk.ActionRead},
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
OrganizationPermissions: nil,
UserPermissions: nil,
templateAdminCustom := func(orgID uuid.UUID) codersdk.Role {
return codersdk.Role{
Name: "test-role",
DisplayName: "Testing Purposes",
OrganizationID: orgID.String(),
// Basically creating a template admin manually
SitePermissions: nil,
OrganizationPermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionViewInsights},
codersdk.ResourceFile: {codersdk.ActionCreate, codersdk.ActionRead},
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
UserPermissions: nil,
}
}
// Create, assign, and use a custom role
@ -50,31 +53,43 @@ func TestCustomRole(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
//nolint:gocritic // owner is required for this
role, err := owner.PatchRole(ctx, templateAdminCustom)
role, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, templateAdminCustom(first.OrganizationID))
require.NoError(t, err, "upsert role")
// Assign the custom template admin role
tmplAdmin, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name)
tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.FullName())
// Assert the role exists
roleNamesF := func(role codersdk.SlimRole) string { return role.Name }
require.Contains(t, db2sdk.List(user.Roles, roleNamesF), role.Name)
// TODO: At present user roles are not returned by the user endpoints.
// Changing this might mess up the UI in how it renders the roles on the
// users page. When the users endpoint is updated, this should be uncommented.
// roleNamesF := func(role codersdk.SlimRole) string { return role.Name }
// require.Contains(t, db2sdk.List(user.Roles, roleNamesF), role.Name)
// Try to create a template version
coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil)
// Verify the role exists in the list
allRoles, err := tmplAdmin.ListSiteRoles(ctx)
allRoles, err := tmplAdmin.ListOrganizationRoles(ctx, first.OrganizationID)
require.NoError(t, err)
var foundRole codersdk.AssignableRoles
require.True(t, slices.ContainsFunc(allRoles, func(selected codersdk.AssignableRoles) bool {
return selected.Name == role.Name
}), "role missing from site role list")
if selected.Name == role.Name {
foundRole = selected
return true
}
return false
}), "role missing from org role list")
require.Len(t, foundRole.SitePermissions, 0)
require.Len(t, foundRole.OrganizationPermissions, 7)
require.Len(t, foundRole.UserPermissions, 0)
})
// Revoked licenses cannot modify/create custom roles, but they can
// use the existing roles.
t.Run("Revoked License", func(t *testing.T) {
t.Run("RevokedLicense", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)}
@ -92,7 +107,7 @@ func TestCustomRole(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
//nolint:gocritic // owner is required for this
role, err := owner.PatchRole(ctx, templateAdminCustom)
role, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, templateAdminCustom(first.OrganizationID))
require.NoError(t, err, "upsert role")
// Remove the license to block enterprise functionality
@ -105,11 +120,11 @@ func TestCustomRole(t *testing.T) {
}
// Verify functionality is lost
_, err = owner.PatchRole(ctx, templateAdminCustom)
require.ErrorContains(t, err, "Custom roles is an Enterprise feature", "upsert role")
_, err = owner.PatchOrganizationRole(ctx, first.OrganizationID, templateAdminCustom(first.OrganizationID))
require.ErrorContains(t, err, "roles are not enabled")
// Assign the custom template admin role
tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name)
tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.FullName())
// Try to create a template version, eg using the custom role
coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil)
@ -133,26 +148,24 @@ func TestCustomRole(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
//nolint:gocritic // owner is required for this
role, err := owner.PatchRole(ctx, templateAdminCustom)
role, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, templateAdminCustom(first.OrganizationID))
require.NoError(t, err, "upsert role")
// Assign the custom template admin role
tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name)
tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.FullName())
// Try to create a template version, eg using the custom role
coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil)
//nolint:gocritic // owner is required for this
role, err = owner.PatchRole(ctx, codersdk.Role{
Name: templateAdminCustom.Name,
DisplayName: templateAdminCustom.DisplayName,
// These are all left nil, which sets the custom role to have 0
// permissions. Omitting this does not "inherit" what already
// exists.
SitePermissions: nil,
OrganizationPermissions: nil,
UserPermissions: nil,
})
newRole := templateAdminCustom(first.OrganizationID)
// These are all left nil, which sets the custom role to have 0
// permissions. Omitting this does not "inherit" what already
// exists.
newRole.SitePermissions = nil
newRole.OrganizationPermissions = nil
newRole.UserPermissions = nil
_, err = owner.PatchOrganizationRole(ctx, first.OrganizationID, newRole)
require.NoError(t, err, "upsert role with override")
// The role should no longer have template perms
@ -172,7 +185,7 @@ func TestCustomRole(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)}
owner, _ := coderdenttest.New(t, &coderdenttest.Options{
owner, first := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
@ -186,18 +199,103 @@ func TestCustomRole(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
//nolint:gocritic // owner is required for this
_, err := owner.PatchRole(ctx, codersdk.Role{
Name: "Bad_Name", // No underscores allowed
DisplayName: "Testing Purposes",
// Basically creating a template admin manually
SitePermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionViewInsights},
codersdk.ResourceFile: {codersdk.ActionCreate, codersdk.ActionRead},
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}),
_, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, codersdk.Role{
Name: "Bad_Name", // No underscores allowed
DisplayName: "Testing Purposes",
SitePermissions: nil,
OrganizationPermissions: nil,
UserPermissions: nil,
})
require.ErrorContains(t, err, "Validation")
})
t.Run("MismatchedOrganizations", 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,
},
},
})
ctx := testutil.Context(t, testutil.WaitMedium)
//nolint:gocritic // owner is required for this
_, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, templateAdminCustom(uuid.New()))
require.ErrorContains(t, err, "does not match")
})
// Attempt to add site & user permissions, which is not allowed
t.Run("ExcessPermissions", 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,
},
},
})
ctx := testutil.Context(t, testutil.WaitMedium)
siteRole := templateAdminCustom(first.OrganizationID)
siteRole.SitePermissions = []codersdk.Permission{
{
ResourceType: codersdk.ResourceWorkspace,
Action: codersdk.ActionRead,
},
}
//nolint:gocritic // owner is required for this
_, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, siteRole)
require.ErrorContains(t, err, "site wide permissions")
userRole := templateAdminCustom(first.OrganizationID)
userRole.UserPermissions = []codersdk.Permission{
{
ResourceType: codersdk.ResourceWorkspace,
Action: codersdk.ActionRead,
},
}
//nolint:gocritic // owner is required for this
_, err = owner.PatchOrganizationRole(ctx, first.OrganizationID, userRole)
require.ErrorContains(t, err, "not allowed to assign user permissions")
})
t.Run("InvalidUUID", 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,
},
},
})
ctx := testutil.Context(t, testutil.WaitMedium)
newRole := templateAdminCustom(first.OrganizationID)
newRole.OrganizationID = "0000" // This is not a valid uuid
//nolint:gocritic // owner is required for this
_, err := owner.PatchOrganizationRole(ctx, first.OrganizationID, newRole)
require.ErrorContains(t, err, "Invalid request")
})
}

View File

@ -14,31 +14,6 @@ import (
"github.com/coder/coder/v2/codersdk"
)
func (api *API) customRolesEnabledMW(next http.Handler) http.Handler {
return httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentCustomRoles)(
http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// Entitlement must be enabled.
api.entitlementsMu.RLock()
entitled := api.entitlements.Features[codersdk.FeatureCustomRoles].Entitlement != codersdk.EntitlementNotEntitled
enabled := api.entitlements.Features[codersdk.FeatureCustomRoles].Enabled
api.entitlementsMu.RUnlock()
if !entitled {
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
Message: "Custom roles is an Enterprise feature. Contact sales!",
})
return
}
if !enabled {
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
Message: "Custom roles is not enabled",
})
return
}
next.ServeHTTP(rw, r)
}))
}
func (api *API) autostopRequirementEnabledMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// Entitlement must be enabled.