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

84
coderd/apidoc/docs.go generated
View File

@ -2225,6 +2225,42 @@ const docTemplate = `{
}
}
}
},
"patch": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Upsert a custom organization role",
"operationId": "upsert-a-custom-organization-role",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Role"
}
}
}
}
}
},
"/organizations/{organization}/members/{user}/roles": {
@ -4362,32 +4398,6 @@ 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}": {
@ -8426,13 +8436,10 @@ const docTemplate = `{
"format": "uuid"
},
"organization_permissions": {
"description": "map[\u003corg_id\u003e] -\u003e Permissions",
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
"description": "OrganizationPermissions are specific for the organization in the field 'OrganizationID' above.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
},
"site_permissions": {
@ -11250,13 +11257,10 @@ const docTemplate = `{
"format": "uuid"
},
"organization_permissions": {
"description": "map[\u003corg_id\u003e] -\u003e Permissions",
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
"description": "OrganizationPermissions are specific for the organization in the field 'OrganizationID' above.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
},
"site_permissions": {

View File

@ -1942,6 +1942,38 @@
}
}
}
},
"patch": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Members"],
"summary": "Upsert a custom organization role",
"operationId": "upsert-a-custom-organization-role",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Role"
}
}
}
}
}
},
"/organizations/{organization}/members/{user}/roles": {
@ -3841,28 +3873,6 @@
}
}
}
},
"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}": {
@ -7481,13 +7491,10 @@
"format": "uuid"
},
"organization_permissions": {
"description": "map[\u003corg_id\u003e] -\u003e Permissions",
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
"description": "OrganizationPermissions are specific for the organization in the field 'OrganizationID' above.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
},
"site_permissions": {
@ -10142,13 +10149,10 @@
"format": "uuid"
},
"organization_permissions": {
"description": "map[\u003corg_id\u003e] -\u003e Permissions",
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
"description": "OrganizationPermissions are specific for the organization in the field 'OrganizationID' above.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
},
"site_permissions": {

View File

@ -424,6 +424,7 @@ func New(options *Options) *API {
TemplateScheduleStore: options.TemplateScheduleStore,
UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore,
AccessControlStore: options.AccessControlStore,
CustomRoleHandler: atomic.Pointer[CustomRoleHandler]{},
Experiments: experiments,
healthCheckGroup: &singleflight.Group[string, *healthsdk.HealthcheckReport]{},
Acquirer: provisionerdserver.NewAcquirer(
@ -436,6 +437,8 @@ func New(options *Options) *API {
workspaceUsageTracker: options.WorkspaceUsageTracker,
}
var customRoleHandler CustomRoleHandler = &agplCustomRoleHandler{}
api.CustomRoleHandler.Store(&customRoleHandler)
api.AppearanceFetcher.Store(&appearance.DefaultFetcher)
api.PortSharer.Store(&portsharing.DefaultPortSharer)
buildInfo := codersdk.BuildInfoResponse{
@ -828,7 +831,12 @@ func New(options *Options) *API {
})
})
r.Route("/members", func(r chi.Router) {
r.Get("/roles", api.assignableOrgRoles)
r.Route("/roles", func(r chi.Router) {
r.Get("/", api.assignableOrgRoles)
r.With(httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentCustomRoles)).
Patch("/", api.patchOrgRoles)
})
r.Route("/{user}", func(r chi.Router) {
r.Use(
httpmw.ExtractOrganizationMemberParam(options.Database),
@ -1249,6 +1257,8 @@ type API struct {
// passed to dbauthz.
AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore]
PortSharer atomic.Pointer[portsharing.PortSharer]
// CustomRoleHandler is the AGPL/Enterprise implementation for custom roles.
CustomRoleHandler atomic.Pointer[CustomRoleHandler]
HTTPAuth *HTTPAuthorizer

View File

@ -531,12 +531,16 @@ func Role(role rbac.Role) codersdk.Role {
if err != nil {
roleName = role.Name
}
return codersdk.Role{
Name: roleName,
OrganizationID: orgIDStr,
DisplayName: role.DisplayName,
SitePermissions: List(role.Site, Permission),
OrganizationPermissions: Map(role.Org, ListLazy(Permission)),
Name: roleName,
OrganizationID: orgIDStr,
DisplayName: role.DisplayName,
SitePermissions: List(role.Site, Permission),
// This is not perfect. If there are organization permissions in another
// organization, they will be omitted. This should not be allowed, so
// should never happen.
OrganizationPermissions: List(role.Org[orgIDStr], Permission),
UserPermissions: List(role.User, Permission),
}
}
@ -550,11 +554,18 @@ func Permission(permission rbac.Permission) codersdk.Permission {
}
func RoleToRBAC(role codersdk.Role) rbac.Role {
orgPerms := map[string][]rbac.Permission{}
if role.OrganizationID != "" {
orgPerms = map[string][]rbac.Permission{
role.OrganizationID: List(role.OrganizationPermissions, PermissionToRBAC),
}
}
return rbac.Role{
Name: rbac.RoleName(role.Name, role.OrganizationID),
DisplayName: role.DisplayName,
Site: List(role.SitePermissions, PermissionToRBAC),
Org: Map(role.OrganizationPermissions, ListLazy(PermissionToRBAC)),
Org: orgPerms,
User: List(role.UserPermissions, PermissionToRBAC),
}
}

View File

@ -600,7 +600,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
customRoles := make([]string, 0)
// Validate that the roles being assigned are valid.
for _, r := range grantedRoles {
_, isOrgRole := rbac.IsOrgRole(r)
roleOrgIDStr, isOrgRole := rbac.IsOrgRole(r)
if shouldBeOrgRoles && !isOrgRole {
return xerrors.Errorf("Must only update org roles")
}
@ -608,6 +608,21 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
return xerrors.Errorf("Must only update site wide roles")
}
if shouldBeOrgRoles {
roleOrgID, err := uuid.Parse(roleOrgIDStr)
if err != nil {
return xerrors.Errorf("role %q has invalid uuid for org: %w", r, err)
}
if orgID == nil {
return xerrors.Errorf("should never happen, orgID is nil, but trying to assign an organization role")
}
if roleOrgID != *orgID {
return xerrors.Errorf("attempted to assign role from a different org, role %q to %q", r, orgID.String())
}
}
// All roles should be valid roles
if _, err := rbac.RoleByName(r); err != nil {
customRoles = append(customRoles, r)

View File

@ -1,13 +1,8 @@
package coderd
import (
"context"
"net/http"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/rbac"
@ -48,7 +43,7 @@ func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) {
return
}
updatedUser, err := api.updateOrganizationMemberRoles(ctx, database.UpdateMemberRolesParams{
updatedUser, err := api.Database.UpdateMemberRoles(ctx, database.UpdateMemberRolesParams{
GrantedRoles: params.Roles,
UserID: member.UserID,
OrgID: organization.ID,
@ -63,36 +58,6 @@ func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, convertOrganizationMember(updatedUser))
}
func (api *API) updateOrganizationMemberRoles(ctx context.Context, args database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
// Enforce only site wide roles
for _, r := range args.GrantedRoles {
// Must be an org role for the org in the args
orgID, ok := rbac.IsOrgRole(r)
if !ok {
return database.OrganizationMember{}, xerrors.Errorf("must only update organization roles")
}
roleOrg, err := uuid.Parse(orgID)
if err != nil {
return database.OrganizationMember{}, xerrors.Errorf("Role must have proper UUIDs for organization, %q does not", r)
}
if roleOrg != args.OrgID {
return database.OrganizationMember{}, xerrors.Errorf("Must only pass roles for org %q", args.OrgID.String())
}
if _, err := rbac.RoleByName(r); err != nil {
return database.OrganizationMember{}, xerrors.Errorf("%q is not a supported organization role", r)
}
}
updatedUser, err := api.Database.UpdateMemberRoles(ctx, args)
if err != nil {
return database.OrganizationMember{}, xerrors.Errorf("Update site roles: %w", err)
}
return updatedUser, nil
}
func convertOrganizationMember(mem database.OrganizationMember) codersdk.OrganizationMember {
convertedMember := codersdk.OrganizationMember{
UserID: mem.UserID,

View File

@ -1,6 +1,7 @@
package coderd
import (
"context"
"net/http"
"github.com/google/uuid"
@ -16,6 +17,52 @@ import (
"github.com/coder/coder/v2/coderd/rbac"
)
// CustomRoleHandler handles AGPL/Enterprise interface for handling custom
// roles. Ideally only included in the enterprise package, but the routes are
// intermixed with AGPL endpoints.
type CustomRoleHandler interface {
PatchOrganizationRole(ctx context.Context, db database.Store, rw http.ResponseWriter, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool)
}
type agplCustomRoleHandler struct{}
func (agplCustomRoleHandler) PatchOrganizationRole(ctx context.Context, _ database.Store, rw http.ResponseWriter, _ uuid.UUID, _ codersdk.Role) (codersdk.Role, bool) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Creating and updating custom roles is an Enterprise feature. Contact sales!",
})
return codersdk.Role{}, false
}
// patchRole will allow creating a custom organization role
//
// @Summary Upsert a custom organization role
// @ID upsert-a-custom-organization-role
// @Security CoderSessionToken
// @Produce json
// @Param organization path string true "Organization ID" format(uuid)
// @Tags Members
// @Success 200 {array} codersdk.Role
// @Router /organizations/{organization}/members/roles [patch]
func (api *API) patchOrgRoles(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
handler = *api.CustomRoleHandler.Load()
organization = httpmw.OrganizationParam(r)
)
var req codersdk.Role
if !httpapi.Read(ctx, rw, r, &req) {
return
}
updated, ok := handler.PatchOrganizationRole(ctx, api.Database, rw, organization.ID, req)
if !ok {
return
}
httpapi.Write(ctx, rw, http.StatusOK, updated)
}
// AssignableSiteRoles returns all site wide roles that can be assigned.
//
// @Summary Get site member roles