chore: implement api for creating custom roles (#13298)

api endpoint (gated by experiment) to create custom_roles
This commit is contained in:
Steven Masley
2024-05-16 13:47:47 -05:00
committed by GitHub
parent 85de0e966d
commit ad8c314130
33 changed files with 1009 additions and 132 deletions

87
coderd/apidoc/docs.go generated
View File

@ -4286,6 +4286,32 @@ const docTemplate = `{
}
}
}
},
"patch": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Upsert a custom site-wide role",
"operationId": "upsert-a-custom-site-wide-role",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Role"
}
}
}
}
}
},
"/users/{user}": {
@ -9547,17 +9573,20 @@ const docTemplate = `{
"enum": [
"example",
"auto-fill-parameters",
"multi-organization"
"multi-organization",
"custom-roles"
],
"x-enum-comments": {
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
"ExperimentCustomRoles": "Allows creating runtime custom roles",
"ExperimentExample": "This isn't used for anything.",
"ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed."
},
"x-enum-varnames": [
"ExperimentExample",
"ExperimentAutoFillParameters",
"ExperimentMultiOrganization"
"ExperimentMultiOrganization",
"ExperimentCustomRoles"
]
},
"codersdk.ExternalAuth": {
@ -10372,7 +10401,7 @@ const docTemplate = `{
"roles": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Role"
"$ref": "#/definitions/codersdk.SlimRole"
}
},
"updated_at": {
@ -10452,6 +10481,21 @@ const docTemplate = `{
}
}
},
"codersdk.Permission": {
"type": "object",
"properties": {
"action": {
"$ref": "#/definitions/codersdk.RBACAction"
},
"negate": {
"description": "Negate makes this a negative permission",
"type": "boolean"
},
"resource_type": {
"$ref": "#/definitions/codersdk.RBACResource"
}
}
},
"codersdk.PostOAuth2ProviderAppRequest": {
"type": "object",
"required": [
@ -11094,6 +11138,28 @@ const docTemplate = `{
},
"name": {
"type": "string"
},
"organization_permissions": {
"description": "map[\u003corg_id\u003e] -\u003e Permissions",
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
}
},
"site_permissions": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
},
"user_permissions": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
}
}
},
@ -11160,6 +11226,17 @@ const docTemplate = `{
}
}
},
"codersdk.SlimRole": {
"type": "object",
"properties": {
"display_name": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"codersdk.SupportConfig": {
"type": "object",
"properties": {
@ -11677,7 +11754,7 @@ const docTemplate = `{
"roles": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Role"
"$ref": "#/definitions/codersdk.SlimRole"
}
},
"status": {
@ -12214,7 +12291,7 @@ const docTemplate = `{
"roles": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Role"
"$ref": "#/definitions/codersdk.SlimRole"
}
},
"status": {

View File

@ -3775,6 +3775,28 @@
}
}
}
},
"patch": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Members"],
"summary": "Upsert a custom site-wide role",
"operationId": "upsert-a-custom-site-wide-role",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Role"
}
}
}
}
}
},
"/users/{user}": {
@ -8545,16 +8567,23 @@
},
"codersdk.Experiment": {
"type": "string",
"enum": ["example", "auto-fill-parameters", "multi-organization"],
"enum": [
"example",
"auto-fill-parameters",
"multi-organization",
"custom-roles"
],
"x-enum-comments": {
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
"ExperimentCustomRoles": "Allows creating runtime custom roles",
"ExperimentExample": "This isn't used for anything.",
"ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed."
},
"x-enum-varnames": [
"ExperimentExample",
"ExperimentAutoFillParameters",
"ExperimentMultiOrganization"
"ExperimentMultiOrganization",
"ExperimentCustomRoles"
]
},
"codersdk.ExternalAuth": {
@ -9316,7 +9345,7 @@
"roles": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Role"
"$ref": "#/definitions/codersdk.SlimRole"
}
},
"updated_at": {
@ -9391,6 +9420,21 @@
}
}
},
"codersdk.Permission": {
"type": "object",
"properties": {
"action": {
"$ref": "#/definitions/codersdk.RBACAction"
},
"negate": {
"description": "Negate makes this a negative permission",
"type": "boolean"
},
"resource_type": {
"$ref": "#/definitions/codersdk.RBACResource"
}
}
},
"codersdk.PostOAuth2ProviderAppRequest": {
"type": "object",
"required": ["callback_url", "name"],
@ -9996,6 +10040,28 @@
},
"name": {
"type": "string"
},
"organization_permissions": {
"description": "map[\u003corg_id\u003e] -\u003e Permissions",
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
}
},
"site_permissions": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
},
"user_permissions": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
}
}
},
@ -10062,6 +10128,17 @@
}
}
},
"codersdk.SlimRole": {
"type": "object",
"properties": {
"display_name": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"codersdk.SupportConfig": {
"type": "object",
"properties": {
@ -10559,7 +10636,7 @@
"roles": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Role"
"$ref": "#/definitions/codersdk.SlimRole"
}
},
"status": {
@ -11053,7 +11130,7 @@
"roles": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Role"
"$ref": "#/definitions/codersdk.SlimRole"
}
},
"status": {

View File

@ -196,12 +196,12 @@ func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogs
CreatedAt: dblog.UserCreatedAt.Time,
Status: codersdk.UserStatus(dblog.UserStatus.UserStatus),
},
Roles: []codersdk.Role{},
Roles: []codersdk.SlimRole{},
}
for _, roleName := range dblog.UserRoles {
rbacRole, _ := rbac.RoleByName(roleName)
user.Roles = append(user.Roles, db2sdk.Role(rbacRole))
user.Roles = append(user.Roles, db2sdk.SlimRole(rbacRole))
}
}

View File

@ -61,6 +61,7 @@ import (
"github.com/coder/coder/v2/coderd/provisionerdserver"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
"github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/coderd/telemetry"
"github.com/coder/coder/v2/coderd/tracing"
@ -631,6 +632,7 @@ func New(options *Options) *API {
httpmw.AttachRequestID,
httpmw.ExtractRealIP(api.RealIPConfig),
httpmw.Logger(api.Logger),
rolestore.CustomRoleMW,
prometheusMW,
// Build-Version is helpful for debugging.
func(next http.Handler) http.Handler {
@ -915,7 +917,7 @@ func New(options *Options) *API {
r.Post("/logout", api.postLogout)
// These routes query information about site wide roles.
r.Route("/roles", func(r chi.Router) {
r.Get("/", api.assignableSiteRoles)
r.Get("/", api.AssignableSiteRoles)
})
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))

View File

@ -18,6 +18,7 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/parameter"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisionersdk/proto"
@ -28,9 +29,25 @@ import (
// database types to slices of codersdk types.
// Only works if the function takes a single argument.
func List[F any, T any](list []F, convert func(F) T) []T {
into := make([]T, 0, len(list))
for _, item := range list {
into = append(into, convert(item))
return ListLazy(convert)(list)
}
// ListLazy returns the converter function for a list, but does not eval
// the input. Helpful for combining the Map and the List functions.
func ListLazy[F any, T any](convert func(F) T) func(list []F) []T {
return func(list []F) []T {
into := make([]T, 0, len(list))
for _, item := range list {
into = append(into, convert(item))
}
return into
}
}
func Map[K comparable, F any, T any](params map[K]F, convert func(F) T) map[K]T {
into := make(map[K]T)
for k, item := range params {
into[k] = convert(item)
}
return into
}
@ -150,12 +167,20 @@ func User(user database.User, organizationIDs []uuid.UUID) codersdk.User {
convertedUser := codersdk.User{
ReducedUser: ReducedUser(user),
OrganizationIDs: organizationIDs,
Roles: make([]codersdk.Role, 0, len(user.RBACRoles)),
Roles: make([]codersdk.SlimRole, 0, len(user.RBACRoles)),
}
for _, roleName := range user.RBACRoles {
rbacRole, _ := rbac.RoleByName(roleName)
convertedUser.Roles = append(convertedUser.Roles, Role(rbacRole))
rbacRole, err := rbac.RoleByName(roleName)
if err == nil {
convertedUser.Roles = append(convertedUser.Roles, SlimRole(rbacRole))
} else {
// TODO: Fix this for custom roles to display the actual display_name
// Requires plumbing either a cached role value, or the db.
convertedUser.Roles = append(convertedUser.Roles, codersdk.SlimRole{
Name: roleName,
})
}
}
return convertedUser
@ -180,8 +205,8 @@ func Group(group database.Group, members []database.User) codersdk.Group {
}
}
func Role(role rbac.Role) codersdk.Role {
return codersdk.Role{
func SlimRole(role rbac.Role) codersdk.SlimRole {
return codersdk.SlimRole{
DisplayName: role.DisplayName,
Name: role.Name,
}
@ -500,3 +525,39 @@ func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.Provisioner
}
return result
}
func Role(role rbac.Role) codersdk.Role {
return codersdk.Role{
Name: role.Name,
DisplayName: role.DisplayName,
SitePermissions: List(role.Site, Permission),
OrganizationPermissions: Map(role.Org, ListLazy(Permission)),
UserPermissions: List(role.Site, Permission),
}
}
func Permission(permission rbac.Permission) codersdk.Permission {
return codersdk.Permission{
Negate: permission.Negate,
ResourceType: codersdk.RBACResource(permission.ResourceType),
Action: codersdk.RBACAction(permission.Action),
}
}
func RoleToRBAC(role codersdk.Role) rbac.Role {
return rbac.Role{
Name: role.Name,
DisplayName: role.DisplayName,
Site: List(role.SitePermissions, PermissionToRBAC),
Org: Map(role.OrganizationPermissions, ListLazy(PermissionToRBAC)),
User: List(role.UserPermissions, PermissionToRBAC),
}
}
func PermissionToRBAC(permission codersdk.Permission) rbac.Permission {
return rbac.Permission{
Negate: permission.Negate,
ResourceType: string(permission.ResourceType),
Action: policy.Action(permission.Action),
}
}

View File

@ -620,7 +620,8 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
}
if len(customRoles) > 0 {
expandedCustomRoles, err := q.CustomRolesByName(ctx, customRoles)
// Leverage any custom role cache that might exist.
expandedCustomRoles, err := rolestore.Expand(ctx, q.db, customRoles)
if err != nil {
return xerrors.Errorf("fetching custom roles: %w", err)
}
@ -632,7 +633,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
// Stop at the first one found. We could make a better error that
// returns them all, but then someone could pass in a large list to make us do
// a lot of loop iterations.
if !slices.ContainsFunc(expandedCustomRoles, func(customRole database.CustomRole) bool {
if !slices.ContainsFunc(expandedCustomRoles, func(customRole rbac.Role) bool {
return strings.EqualFold(customRole.Name, role)
}) {
return xerrors.Errorf("%q is not a supported role", role)

View File

@ -24,6 +24,7 @@ import (
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/promoauth"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
"github.com/coder/coder/v2/codersdk"
)
@ -437,11 +438,21 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
})
}
//nolint:gocritic // Permission to lookup custom roles the user has assigned.
rbacRoles, err := rolestore.Expand(dbauthz.AsSystemRestricted(ctx), cfg.DB, roles.Roles)
if err != nil {
return write(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to expand authenticated user roles",
Detail: err.Error(),
Validations: nil,
})
}
// Actor is the user's authorization context.
actor := rbac.Subject{
FriendlyName: roles.Username,
ID: key.UserID.String(),
Roles: rbac.RoleNames(roles.Roles),
Roles: rbacRoles,
Groups: roles.Groups,
Scope: rbac.ScopeName(key.Scope),
}.WithCachedASTValue()

View File

@ -82,7 +82,7 @@ func (api *API) updateOrganizationMemberRoles(ctx context.Context, args database
}
if _, err := rbac.RoleByName(r); err != nil {
return database.OrganizationMember{}, xerrors.Errorf("%q is not a supported role", r)
return database.OrganizationMember{}, xerrors.Errorf("%q is not a supported organization role", r)
}
}
@ -99,12 +99,12 @@ func convertOrganizationMember(mem database.OrganizationMember) codersdk.Organiz
OrganizationID: mem.OrganizationID,
CreatedAt: mem.CreatedAt,
UpdatedAt: mem.UpdatedAt,
Roles: make([]codersdk.Role, 0, len(mem.Roles)),
Roles: make([]codersdk.SlimRole, 0, len(mem.Roles)),
}
for _, roleName := range mem.Roles {
rbacRole, _ := rbac.RoleByName(roleName)
convertedMember.Roles = append(convertedMember.Roles, db2sdk.Role(rbacRole))
convertedMember.Roles = append(convertedMember.Roles, db2sdk.SlimRole(rbacRole))
}
return convertedMember
}

View File

@ -1,14 +1,96 @@
package rolestore
import (
"context"
"encoding/json"
"net/http"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/syncmap"
)
type customRoleCtxKey struct{}
// CustomRoleMW adds a custom role cache on the ctx to prevent duplicate
// db fetches.
func CustomRoleMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(CustomRoleCacheContext(r.Context()))
next.ServeHTTP(w, r)
})
}
// CustomRoleCacheContext prevents needing to lookup custom roles within the
// same request lifecycle. Optimizing this to span requests should be done
// in the future.
func CustomRoleCacheContext(ctx context.Context) context.Context {
return context.WithValue(ctx, customRoleCtxKey{}, syncmap.New[string, rbac.Role]())
}
func roleCache(ctx context.Context) *syncmap.Map[string, rbac.Role] {
c, ok := ctx.Value(customRoleCtxKey{}).(*syncmap.Map[string, rbac.Role])
if !ok {
return syncmap.New[string, rbac.Role]()
}
return c
}
// Expand will expand built in roles, and fetch custom roles from the database.
func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, error) {
if len(names) == 0 {
// That was easy
return []rbac.Role{}, nil
}
cache := roleCache(ctx)
lookup := make([]string, 0)
roles := make([]rbac.Role, 0, len(names))
for _, name := range names {
// Remove any built in roles
expanded, err := rbac.RoleByName(name)
if err == nil {
roles = append(roles, expanded)
continue
}
// Check custom role cache
customRole, ok := cache.Load(name)
if ok {
roles = append(roles, customRole)
continue
}
// Defer custom role lookup
lookup = append(lookup, name)
}
if len(lookup) > 0 {
// If some roles are missing from the database, they are omitted from
// the expansion. These roles are no-ops. Should we raise some kind of
// warning when this happens?
dbroles, err := db.CustomRolesByName(ctx, lookup)
if err != nil {
return nil, xerrors.Errorf("fetch custom roles: %w", err)
}
// convert dbroles -> roles
for _, dbrole := range dbroles {
converted, err := ConvertDBRole(dbrole)
if err != nil {
return nil, xerrors.Errorf("convert db role %q: %w", dbrole, err)
}
roles = append(roles, converted)
cache.Store(dbrole.Name, converted)
}
}
return roles, nil
}
func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) {
role := rbac.Role{
Name: dbRole.Name,
@ -35,3 +117,30 @@ func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) {
return role, nil
}
func ConvertRoleToDB(role rbac.Role) (database.CustomRole, error) {
dbRole := database.CustomRole{
Name: role.Name,
DisplayName: role.DisplayName,
}
siteData, err := json.Marshal(role.Site)
if err != nil {
return dbRole, xerrors.Errorf("marshal site permissions: %w", err)
}
dbRole.SitePermissions = siteData
orgData, err := json.Marshal(role.Org)
if err != nil {
return dbRole, xerrors.Errorf("marshal org permissions: %w", err)
}
dbRole.OrgPermissions = orgData
userData, err := json.Marshal(role.User)
if err != nil {
return dbRole, xerrors.Errorf("marshal user permissions: %w", err)
}
dbRole.UserPermissions = userData
return dbRole, nil
}

View File

@ -11,7 +11,7 @@ import (
"github.com/coder/coder/v2/coderd/rbac"
)
// assignableSiteRoles returns all site wide roles that can be assigned.
// AssignableSiteRoles returns all site wide roles that can be assigned.
//
// @Summary Get site member roles
// @ID get-site-member-roles
@ -20,7 +20,7 @@ import (
// @Tags Members
// @Success 200 {array} codersdk.AssignableRoles
// @Router /users/roles [get]
func (api *API) assignableSiteRoles(rw http.ResponseWriter, r *http.Request) {
func (api *API) AssignableSiteRoles(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
actorRoles := httpmw.UserAuthorization(r)
if !api.Authorize(r, policy.ActionRead, rbac.ResourceAssignRole) {
@ -32,7 +32,7 @@ func (api *API) assignableSiteRoles(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles))
}
// assignableSiteRoles returns all org wide roles that can be assigned.
// assignableOrgRoles returns all org wide roles that can be assigned.
//
// @Summary Get member roles by organization
// @ID get-member-roles-by-organization
@ -66,7 +66,7 @@ func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role) []coder
continue
}
assignable = append(assignable, codersdk.AssignableRoles{
Role: codersdk.Role{
SlimRole: codersdk.SlimRole{
Name: role.Name,
DisplayName: role.DisplayName,
},

View File

@ -143,9 +143,9 @@ func TestListRoles(t *testing.T) {
}
}
func convertRole(roleName string) codersdk.Role {
func convertRole(roleName string) codersdk.SlimRole {
role, _ := rbac.RoleByName(roleName)
return codersdk.Role{
return codersdk.SlimRole{
DisplayName: role.DisplayName,
Name: role.Name,
}
@ -156,7 +156,7 @@ func convertRoles(assignableRoles map[string]bool) []codersdk.AssignableRoles {
for roleName, assignable := range assignableRoles {
role := convertRole(roleName)
converted = append(converted, codersdk.AssignableRoles{
Role: role,
SlimRole: role,
Assignable: assignable,
})
}

View File

@ -1095,7 +1095,7 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) {
return
}
updatedUser, err := UpdateSiteUserRoles(ctx, api.Database, database.UpdateUserRolesParams{
updatedUser, err := api.Database.UpdateUserRoles(ctx, database.UpdateUserRolesParams{
GrantedRoles: params.Roles,
ID: user.ID,
})
@ -1123,27 +1123,6 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUser, organizationIDs))
}
// UpdateSiteUserRoles will ensure only site wide roles are passed in as arguments.
// If an organization role is included, an error is returned.
func UpdateSiteUserRoles(ctx context.Context, db database.Store, args database.UpdateUserRolesParams) (database.User, error) {
// Enforce only site wide roles.
for _, r := range args.GrantedRoles {
if _, ok := rbac.IsOrgRole(r); ok {
return database.User{}, xerrors.Errorf("Must only update site wide roles")
}
if _, err := rbac.RoleByName(r); err != nil {
return database.User{}, xerrors.Errorf("%q is not a supported role", r)
}
}
updatedUser, err := db.UpdateUserRoles(ctx, args)
if err != nil {
return database.User{}, xerrors.Errorf("update site roles: %w", err)
}
return updatedUser, nil
}
// Returns organizations the parameterized user has access to.
//
// @Summary Get organizations by user

View File

@ -1049,7 +1049,7 @@ func TestGrantSiteRoles(t *testing.T) {
c.AssignToUser = newUser.ID.String()
}
var newRoles []codersdk.Role
var newRoles []codersdk.SlimRole
if c.OrgID != uuid.Nil {
// Org assign
var mem codersdk.OrganizationMember