mirror of
https://github.com/coder/coder.git
synced 2025-07-08 11:39:50 +00:00
chore: implement api for creating custom roles (#13298)
api endpoint (gated by experiment) to create custom_roles
This commit is contained in:
@ -326,6 +326,23 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
||||
r.Put("/", api.putAppearance)
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
|
80
enterprise/coderd/roles.go
Normal file
80
enterprise/coderd/roles.go
Normal file
@ -0,0 +1,80 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/rbac/rolestore"
|
||||
"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()
|
||||
|
||||
var req codersdk.Role
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
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.
|
||||
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",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure all permissions inputted are valid according to our policy.
|
||||
rbacRole := db2sdk.RoleToRBAC(req)
|
||||
args, err := rolestore.ConvertRoleToDB(rbacRole)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid request",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
inserted, err := api.Database.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{
|
||||
Name: args.Name,
|
||||
DisplayName: args.DisplayName,
|
||||
SitePermissions: args.SitePermissions,
|
||||
OrgPermissions: args.OrgPermissions,
|
||||
UserPermissions: args.UserPermissions,
|
||||
})
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to update role permissions",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
convertedInsert, err := rolestore.ConvertDBRole(inserted)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Permissions were updated, unable to read them back out of the database.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Role(convertedInsert))
|
||||
}
|
170
enterprise/coderd/roles_test.go
Normal file
170
enterprise/coderd/roles_test.go
Normal file
@ -0,0 +1,170 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestCustomRole(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,
|
||||
}
|
||||
|
||||
// Create, assign, and use a custom role
|
||||
t.Run("Success", 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
|
||||
role, err := owner.PatchRole(ctx, templateAdminCustom)
|
||||
require.NoError(t, err, "upsert role")
|
||||
|
||||
// Assign the custom template admin role
|
||||
tmplAdmin, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name)
|
||||
|
||||
// Assert the role exists
|
||||
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
|
||||
// TODO: Turn this assertion back on when the cli api experience is created.
|
||||
//allRoles, err := tmplAdmin.ListSiteRoles(ctx)
|
||||
//require.NoError(t, err)
|
||||
//
|
||||
//require.True(t, slices.ContainsFunc(allRoles, func(selected codersdk.AssignableRoles) bool {
|
||||
// return selected.Name == role.Name
|
||||
//}), "role missing from site role list")
|
||||
})
|
||||
|
||||
// Revoked licenses cannot modify/create custom roles, but they can
|
||||
// use the existing roles.
|
||||
t.Run("Revoked License", 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
|
||||
role, err := owner.PatchRole(ctx, templateAdminCustom)
|
||||
require.NoError(t, err, "upsert role")
|
||||
|
||||
// Remove the license to block enterprise functionality
|
||||
licenses, err := owner.Licenses(ctx)
|
||||
require.NoError(t, err, "get licenses")
|
||||
for _, license := range licenses {
|
||||
// Should be only 1...
|
||||
err := owner.DeleteLicense(ctx, license.ID)
|
||||
require.NoError(t, err, "delete license")
|
||||
}
|
||||
|
||||
// Verify functionality is lost
|
||||
_, err = owner.PatchRole(ctx, templateAdminCustom)
|
||||
require.ErrorContains(t, err, "Custom roles is an Enterprise feature", "upsert role")
|
||||
|
||||
// Assign the custom template admin role
|
||||
tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name)
|
||||
|
||||
// Try to create a template version, eg using the custom role
|
||||
coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil)
|
||||
})
|
||||
|
||||
// Role patches are complete, as in the request overrides the existing role.
|
||||
t.Run("RoleOverrides", 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
|
||||
role, err := owner.PatchRole(ctx, templateAdminCustom)
|
||||
require.NoError(t, err, "upsert role")
|
||||
|
||||
// Assign the custom template admin role
|
||||
tmplAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name)
|
||||
|
||||
// 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,
|
||||
})
|
||||
require.NoError(t, err, "upsert role with override")
|
||||
|
||||
// The role should no longer have template perms
|
||||
data, err := echo.TarWithOptions(ctx, tmplAdmin.Logger(), nil)
|
||||
require.NoError(t, err)
|
||||
file, err := tmplAdmin.Upload(ctx, codersdk.ContentTypeTar, bytes.NewReader(data))
|
||||
require.NoError(t, err)
|
||||
_, err = tmplAdmin.CreateTemplateVersion(ctx, first.OrganizationID, codersdk.CreateTemplateVersionRequest{
|
||||
FileID: file.ID,
|
||||
StorageMethod: codersdk.ProvisionerStorageMethodFile,
|
||||
Provisioner: codersdk.ProvisionerTypeEcho,
|
||||
})
|
||||
require.ErrorContains(t, err, "forbidden")
|
||||
})
|
||||
}
|
@ -7,7 +7,6 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@ -96,7 +95,7 @@ func (api *API) setUserSiteRoles(ctx context.Context, logger slog.Logger, db dat
|
||||
|
||||
// Should this be feature protected?
|
||||
return db.InTx(func(tx database.Store) error {
|
||||
_, err := coderd.UpdateSiteUserRoles(ctx, db, database.UpdateUserRolesParams{
|
||||
_, err := db.UpdateUserRoles(ctx, database.UpdateUserRolesParams{
|
||||
GrantedRoles: roles,
|
||||
ID: userID,
|
||||
})
|
||||
|
@ -14,6 +14,31 @@ 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.
|
||||
|
Reference in New Issue
Block a user