Skip to content

Commit 4a38c95

Browse files
committed
chore: implement patching custom organization roles
1 parent 1b4ca00 commit 4a38c95

File tree

9 files changed

+205
-12
lines changed

9 files changed

+205
-12
lines changed

cli/cliui/parameter.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
4343
return "", err
4444
}
4545

46-
values, err := MultiSelect(inv, options)
46+
values, err := MultiSelect(inv, MultiSelectOptions{
47+
Options: options,
48+
Defaults: options,
49+
})
4750
if err == nil {
4851
v, err := json.Marshal(&values)
4952
if err != nil {

cli/cliui/select.go

+16-6
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func init() {
2121
{{- .CurrentOpt.Value}}
2222
{{- color "reset"}}
2323
{{end}}
24-
24+
{{- if .Message }}{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}{{ end }}
2525
{{- if not .ShowAnswer }}
2626
{{- if .Config.Icons.Help.Text }}
2727
{{- if .FilterMessage }}{{ "Search:" }}{{ .FilterMessage }}
@@ -44,18 +44,20 @@ func init() {
4444
{{- " "}}{{- .CurrentOpt.Value}}
4545
{{end}}
4646
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
47+
{{- if .Message }}{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}{{ end }}
4748
{{- if not .ShowAnswer }}
4849
{{- "\n"}}
4950
{{- range $ix, $option := .PageEntries}}
5051
{{- template "option" $.IterateOption $ix $option}}
5152
{{- end}}
52-
{{- end}}`
53+
{{- end }}`
5354
}
5455

5556
type SelectOptions struct {
5657
Options []string
5758
// Default will be highlighted first if it's a valid option.
5859
Default string
60+
Message string
5961
Size int
6062
HideSearch bool
6163
}
@@ -122,6 +124,7 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
122124
Options: opts.Options,
123125
Default: defaultOption,
124126
PageSize: opts.Size,
127+
Message: opts.Message,
125128
}, &value, survey.WithIcons(func(is *survey.IconSet) {
126129
is.Help.Text = "Type to search"
127130
if opts.HideSearch {
@@ -138,15 +141,22 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
138141
return value, err
139142
}
140143

141-
func MultiSelect(inv *serpent.Invocation, items []string) ([]string, error) {
144+
type MultiSelectOptions struct {
145+
Message string
146+
Options []string
147+
Defaults []string
148+
}
149+
150+
func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) {
142151
// Similar hack is applied to Select()
143152
if flag.Lookup("test.v") != nil {
144-
return items, nil
153+
return opts.Defaults, nil
145154
}
146155

147156
prompt := &survey.MultiSelect{
148-
Options: items,
149-
Default: items,
157+
Message: opts.Message,
158+
Options: opts.Options,
159+
Default: opts.Defaults,
150160
}
151161

152162
var values []string

cli/cliui/select_test.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
107107
var values []string
108108
cmd := &serpent.Command{
109109
Handler: func(inv *serpent.Invocation) error {
110-
selectedItems, err := cliui.MultiSelect(inv, items)
110+
selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
111+
Options: items,
112+
Defaults: items,
113+
})
111114
if err == nil {
112115
values = selectedItems
113116
}

coderd/coderd.go

+4-2
Original file line numberDiff line numberDiff line change
@@ -812,8 +812,6 @@ func New(options *Options) *API {
812812
httpmw.ExtractOrganizationParam(options.Database),
813813
)
814814
r.Get("/", api.organization)
815-
r.Patch("/", api.patchOrganization)
816-
r.Delete("/", api.deleteOrganization)
817815
r.Post("/templateversions", api.postTemplateVersionsByOrganization)
818816
r.Route("/templates", func(r chi.Router) {
819817
r.Post("/", api.postTemplateByOrganization)
@@ -829,6 +827,8 @@ func New(options *Options) *API {
829827
})
830828
r.Route("/members", func(r chi.Router) {
831829
r.Get("/roles", api.assignableOrgRoles)
830+
r.With(httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentCustomRoles)).
831+
Patch("/roles", api.patchOrgRoles)
832832
r.Route("/{user}", func(r chi.Router) {
833833
r.Use(
834834
httpmw.ExtractOrganizationMemberParam(options.Database),
@@ -1249,6 +1249,8 @@ type API struct {
12491249
// passed to dbauthz.
12501250
AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore]
12511251
PortSharer atomic.Pointer[portsharing.PortSharer]
1252+
// CustomRoleHandler is the AGPL/Enterprise implementation for custom roles.
1253+
CustomRoleHandler atomic.Pointer[CustomRoleHandler]
12521254

12531255
HTTPAuth *HTTPAuthorizer
12541256

coderd/roles.go

+54
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package coderd
22

33
import (
4+
"context"
45
"net/http"
56

67
"github.com/google/uuid"
@@ -16,6 +17,59 @@ import (
1617
"github.com/coder/coder/v2/coderd/rbac"
1718
)
1819

20+
// CustomRoleHandler handles AGPL/Enterprise interface for handling custom
21+
// roles. Ideally only included in the enterprise package, but the routes are
22+
// intermixed with AGPL endpoints.
23+
type CustomRoleHandler interface {
24+
PatchOrganizationRole(ctx context.Context, db database.Store, rw http.ResponseWriter, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool)
25+
}
26+
27+
type agplCustomRoleHandler struct{}
28+
29+
func (agplCustomRoleHandler) PatchOrganizationRole(ctx context.Context, db database.Store, rw http.ResponseWriter, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool) {
30+
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
31+
Message: "Creating and updating custom roles is an Enterprise feature. Contact sales!",
32+
})
33+
return codersdk.Role{}, false
34+
}
35+
36+
// patchRole will allow creating a custom organization role
37+
//
38+
// @Summary Upsert a custom organization role
39+
// @ID upsert-a-custom-organization-role
40+
// @Security CoderSessionToken
41+
// @Produce json
42+
// @Tags Members
43+
// @Success 200 {array} codersdk.Role
44+
// @Router /organizations/{organization}/members/roles [patch]
45+
func (api *API) patchOrgRoles(rw http.ResponseWriter, r *http.Request) {
46+
var (
47+
ctx = r.Context()
48+
handler = *api.CustomRoleHandler.Load()
49+
organization = httpmw.OrganizationParam(r)
50+
)
51+
52+
var req codersdk.Role
53+
if !httpapi.Read(ctx, rw, r, &req) {
54+
return
55+
}
56+
57+
if err := httpapi.NameValid(req.Name); err != nil {
58+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
59+
Message: "Invalid role name",
60+
Detail: err.Error(),
61+
})
62+
return
63+
}
64+
65+
updated, ok := handler.PatchOrganizationRole(ctx, api.Database, rw, organization.ID, req)
66+
if !ok {
67+
return
68+
}
69+
70+
httpapi.Write(ctx, rw, http.StatusOK, updated)
71+
}
72+
1973
// AssignableSiteRoles returns all site wide roles that can be assigned.
2074
//
2175
// @Summary Get site member roles

enterprise/coderd/coderd.go

+5
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,11 @@ func (api *API) updateEntitlements(ctx context.Context) error {
761761
api.AGPL.PortSharer.Store(&ps)
762762
}
763763

764+
if initial, changed, enabled := featureChanged(codersdk.FeatureCustomRoles); shouldUpdate(initial, changed, enabled) {
765+
var handler coderd.CustomRoleHandler = &enterpriseCustomRoleHandler{Enabled: enabled}
766+
api.AGPL.CustomRoleHandler.Store(&handler)
767+
}
768+
764769
// External token encryption is soft-enforced
765770
featureExternalTokenEncryption := entitlements.Features[codersdk.FeatureExternalTokenEncryption]
766771
featureExternalTokenEncryption.Enabled = len(api.ExternalTokenEncryption) > 0

enterprise/coderd/roles.go

+96-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package coderd
22

33
import (
4+
"context"
5+
"fmt"
46
"net/http"
57

68
"github.com/google/uuid"
@@ -12,6 +14,100 @@ import (
1214
"github.com/coder/coder/v2/codersdk"
1315
)
1416

17+
type enterpriseCustomRoleHandler struct {
18+
Enabled bool
19+
}
20+
21+
func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context, db database.Store, rw http.ResponseWriter, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool) {
22+
if !h.Enabled {
23+
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
24+
Message: "Custom roles is not enabled",
25+
})
26+
return codersdk.Role{}, false
27+
}
28+
29+
// Only organization permissions are allowed to be granted
30+
if len(role.SitePermissions) > 0 {
31+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
32+
Message: "Invalid request, not allowed to assign site wide permissions for an organization role.",
33+
Detail: "organization scoped roles may not contain site wide permissions",
34+
})
35+
return codersdk.Role{}, false
36+
}
37+
38+
if len(role.UserPermissions) > 0 {
39+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
40+
Message: "Invalid request, not allowed to assign user permissions for an organization role.",
41+
Detail: "organization scoped roles may not contain user permissions",
42+
})
43+
return codersdk.Role{}, false
44+
}
45+
46+
if len(role.OrganizationPermissions) > 1 {
47+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
48+
Message: "Invalid request, Only 1 organization can be assigned permissions",
49+
Detail: "roles can only contain 1 organization",
50+
})
51+
return codersdk.Role{}, false
52+
}
53+
54+
if len(role.OrganizationPermissions) == 1 {
55+
_, exists := role.OrganizationPermissions[orgID.String()]
56+
if !exists {
57+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
58+
Message: fmt.Sprintf("Invalid request, expected permissions for only the organization %q", orgID.String()),
59+
Detail: fmt.Sprintf("only org id %s allowed", orgID.String()),
60+
})
61+
return codersdk.Role{}, false
62+
}
63+
}
64+
65+
// Make sure all permissions inputted are valid according to our policy.
66+
rbacRole := db2sdk.RoleToRBAC(role)
67+
args, err := rolestore.ConvertRoleToDB(rbacRole)
68+
if err != nil {
69+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
70+
Message: "Invalid request",
71+
Detail: err.Error(),
72+
})
73+
return codersdk.Role{}, false
74+
}
75+
76+
inserted, err := db.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{
77+
Name: args.Name,
78+
DisplayName: args.DisplayName,
79+
OrganizationID: uuid.NullUUID{
80+
UUID: orgID,
81+
Valid: true,
82+
},
83+
SitePermissions: args.SitePermissions,
84+
OrgPermissions: args.OrgPermissions,
85+
UserPermissions: args.UserPermissions,
86+
})
87+
if httpapi.Is404Error(err) {
88+
httpapi.ResourceNotFound(rw)
89+
return codersdk.Role{}, false
90+
}
91+
if err != nil {
92+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
93+
Message: "Failed to update role permissions",
94+
Detail: err.Error(),
95+
})
96+
return codersdk.Role{}, false
97+
}
98+
99+
convertedInsert, err := rolestore.ConvertDBRole(inserted)
100+
if err != nil {
101+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
102+
Message: "Permissions were updated, unable to read them back out of the database.",
103+
Detail: err.Error(),
104+
})
105+
return codersdk.Role{}, false
106+
}
107+
108+
return db2sdk.Role(convertedInsert), true
109+
}
110+
15111
// patchRole will allow creating a custom role
16112
//
17113
// @Summary Upsert a custom site-wide role
@@ -61,7 +157,6 @@ func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) {
61157
inserted, err := api.Database.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{
62158
Name: args.Name,
63159
DisplayName: args.DisplayName,
64-
OrganizationID: uuid.NullUUID{},
65160
SitePermissions: args.SitePermissions,
66161
OrgPermissions: args.OrgPermissions,
67162
UserPermissions: args.UserPermissions,

enterprise/coderd/roles_test.go

+10-1
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,18 @@ func TestCustomRole(t *testing.T) {
6767
allRoles, err := tmplAdmin.ListSiteRoles(ctx)
6868
require.NoError(t, err)
6969

70+
var foundRole codersdk.AssignableRoles
7071
require.True(t, slices.ContainsFunc(allRoles, func(selected codersdk.AssignableRoles) bool {
71-
return selected.Name == role.Name
72+
if selected.Name == role.Name {
73+
foundRole = selected
74+
return true
75+
}
76+
return false
7277
}), "role missing from site role list")
78+
79+
require.Len(t, foundRole.SitePermissions, 7)
80+
require.Len(t, foundRole.OrganizationPermissions, 0)
81+
require.Len(t, foundRole.UserPermissions, 0)
7382
})
7483

7584
// Revoked licenses cannot modify/create custom roles, but they can

scripts/rbacgen/codersdk.gotmpl

+12
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,15 @@ const (
1616
{{ $element.Enum }} RBACAction = "{{ $element.Value }}"
1717
{{- end }}
1818
)
19+
20+
// RBACResourceActions is the mapping of resources to which actions are valid for
21+
// said resource type.
22+
var RBACResourceActions = map[RBACResource][]RBACAction{
23+
{{- range $element := . }}
24+
Resource{{ pascalCaseName $element.FunctionName }}: []RBACAction{
25+
{{- range $actionValue, $_ := $element.Actions }}
26+
{{- actionEnum $actionValue -}},
27+
{{- end -}}
28+
},
29+
{{- end }}
30+
}

0 commit comments

Comments
 (0)