Skip to content

Commit dae5a29

Browse files
committed
fix: better rbac frontend UX for selecting permissions (#2783)
1 parent f314bc5 commit dae5a29

20 files changed

Lines changed: 427 additions & 123 deletions

File tree

backend/api/handlers/customize.go

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ type GetCustomizeCategoriesOutput struct {
3535
Body []category.Category
3636
}
3737

38+
var customizeCategoryPermissionsInternal = map[string][]string{
39+
"templates": {authz.PermTemplatesList, authz.PermTemplatesRead},
40+
"registries": {authz.PermRegistriesList, authz.PermRegistriesRead},
41+
"variables": {authz.PermTemplatesRead},
42+
"git-repositories": {authz.PermGitReposList, authz.PermGitReposRead},
43+
}
44+
3845
// RegisterCustomize registers customization endpoints using Huma.
3946
func RegisterCustomize(api huma.API, customizeSearchService *services.CustomizeSearchService) {
4047
h := &CustomizeHandler{
@@ -52,7 +59,6 @@ func RegisterCustomize(api huma.API, customizeSearchService *services.CustomizeS
5259
{"BearerAuth": {}},
5360
{"ApiKeyAuth": {}},
5461
},
55-
Middlewares: humamw.RequirePermission(api, authz.PermCustomizeManage),
5662
}, h.Search)
5763

5864
huma.Register(api, huma.Operation{
@@ -66,10 +72,41 @@ func RegisterCustomize(api huma.API, customizeSearchService *services.CustomizeS
6672
{"BearerAuth": {}},
6773
{"ApiKeyAuth": {}},
6874
},
69-
Middlewares: humamw.RequirePermission(api, authz.PermCustomizeManage),
7075
}, h.GetCategories)
7176
}
7277

78+
func canAccessCustomizeCategoryInternal(ps *authz.PermissionSet, categoryID string) bool {
79+
if ps == nil {
80+
return false
81+
}
82+
if ps.Allows(authz.PermCustomizeManage, "") {
83+
return true
84+
}
85+
perms, ok := customizeCategoryPermissionsInternal[categoryID]
86+
if !ok {
87+
return false
88+
}
89+
for _, perm := range perms {
90+
if ps.Allows(perm, "") {
91+
return true
92+
}
93+
}
94+
return false
95+
}
96+
97+
func filterCustomizeCategoriesInternal(ps *authz.PermissionSet, categories []category.Category) []category.Category {
98+
if ps == nil {
99+
return []category.Category{}
100+
}
101+
filtered := make([]category.Category, 0, len(categories))
102+
for _, cat := range categories {
103+
if canAccessCustomizeCategoryInternal(ps, cat.ID) {
104+
filtered = append(filtered, cat)
105+
}
106+
}
107+
return filtered
108+
}
109+
73110
// Search searches customization options by query.
74111
func (h *CustomizeHandler) Search(ctx context.Context, input *SearchCustomizeInput) (*SearchCustomizeOutput, error) {
75112
if h.customizeSearchService == nil {
@@ -80,19 +117,10 @@ func (h *CustomizeHandler) Search(ctx context.Context, input *SearchCustomizeInp
80117
return nil, huma.Error400BadRequest((&common.QueryParameterRequiredError{}).Error())
81118
}
82119

83-
results := h.customizeSearchService.Search(input.Body.Query)
84-
85120
ps, _ := humamw.PermissionsFromContext(ctx)
86-
if !ps.IsGlobalAdmin() {
87-
filtered := []category.Category{}
88-
for _, cat := range results.Results {
89-
if cat.ID != "registries" && cat.ID != "variables" {
90-
filtered = append(filtered, cat)
91-
}
92-
}
93-
results.Results = filtered
94-
results.Count = len(filtered)
95-
}
121+
results := h.customizeSearchService.Search(input.Body.Query)
122+
results.Results = filterCustomizeCategoriesInternal(ps, results.Results)
123+
results.Count = len(results.Results)
96124

97125
return &SearchCustomizeOutput{
98126
Body: results,
@@ -105,18 +133,8 @@ func (h *CustomizeHandler) GetCategories(ctx context.Context, input *GetCustomiz
105133
return nil, huma.Error500InternalServerError("service not available")
106134
}
107135

108-
categories := h.customizeSearchService.GetCustomizeCategories()
109-
110136
ps, _ := humamw.PermissionsFromContext(ctx)
111-
if !ps.IsGlobalAdmin() {
112-
filtered := []category.Category{}
113-
for _, cat := range categories {
114-
if cat.ID != "registries" && cat.ID != "variables" {
115-
filtered = append(filtered, cat)
116-
}
117-
}
118-
categories = filtered
119-
}
137+
categories := filterCustomizeCategoriesInternal(ps, h.customizeSearchService.GetCustomizeCategories())
120138

121139
return &GetCustomizeCategoriesOutput{
122140
Body: categories,

backend/api/handlers/roles.go

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,9 @@ func RegisterRoles(api huma.API, roleService *services.RoleService) {
156156
Method: http.MethodGet,
157157
Path: "/roles/available-permissions",
158158
Summary: "Get the permission manifest",
159-
Description: "Returns every permission the server recognizes, grouped by resource. Used by the role editor UI.",
159+
Description: "Returns every permission the server recognizes, grouped by resource. Used by permission-picking UIs.",
160160
Tags: []string{"Roles"},
161161
Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
162-
Middlewares: humamw.RequirePermission(api, authz.PermRolesRead),
163162
}, h.GetPermissionsManifest)
164163

165164
huma.Register(api, huma.Operation{
@@ -408,6 +407,7 @@ func buildPermissionsManifestInternal() roletypes.PermissionsManifest {
408407
Permission: action.Permission,
409408
Label: action.Label,
410409
Description: action.Description,
410+
Requires: requiredPermissionsForPickerInternal(action.Permission),
411411
}
412412
}
413413
resources[i] = roletypes.PermissionResource{
@@ -417,5 +417,53 @@ func buildPermissionsManifestInternal() roletypes.PermissionsManifest {
417417
Actions: actions,
418418
}
419419
}
420-
return roletypes.PermissionsManifest{Resources: resources}
420+
return roletypes.PermissionsManifest{
421+
Resources: resources,
422+
Presets: []roletypes.PermissionPreset{
423+
{
424+
Key: "editor",
425+
Label: "All permissions (non-admin)",
426+
Description: "Matches the built-in Editor permission set.",
427+
Permissions: authz.BuiltInEditorPermissions(),
428+
},
429+
{
430+
Key: "global-admin",
431+
Label: "Global Admin",
432+
Description: "Select every permission in the manifest.",
433+
Permissions: authz.AllPermissions(),
434+
},
435+
},
436+
}
437+
}
438+
439+
func requiredPermissionsForPickerInternal(permission string) []string {
440+
switch permission {
441+
case authz.PermSettingsWrite,
442+
authz.PermApiKeysList,
443+
authz.PermApiKeysRead,
444+
authz.PermApiKeysCreate,
445+
authz.PermApiKeysUpdate,
446+
authz.PermApiKeysDelete,
447+
authz.PermFederatedList,
448+
authz.PermFederatedRead,
449+
authz.PermFederatedCreate,
450+
authz.PermFederatedUpdate,
451+
authz.PermFederatedDelete,
452+
authz.PermUsersList,
453+
authz.PermUsersRead,
454+
authz.PermUsersCreate,
455+
authz.PermUsersUpdate,
456+
authz.PermUsersDelete,
457+
authz.PermRolesList,
458+
authz.PermRolesRead,
459+
authz.PermWebhooksList,
460+
authz.PermWebhooksCreate,
461+
authz.PermWebhooksUpdate,
462+
authz.PermWebhooksDelete,
463+
authz.PermNotificationsManage,
464+
authz.PermDiagnosticsRead:
465+
return []string{authz.PermSettingsRead}
466+
default:
467+
return nil
468+
}
421469
}

backend/api/handlers/settings.go

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ type GetCategoriesOutput struct {
7171
Body []category.Category
7272
}
7373

74+
var settingsCategoryPermissionsInternal = map[string][]string{
75+
"activity": {authz.PermSettingsRead},
76+
"apikeys": {authz.PermApiKeysList, authz.PermApiKeysRead},
77+
"appearance": {authz.PermSettingsRead},
78+
"authentication": {authz.PermSettingsRead},
79+
"build": {authz.PermSettingsRead},
80+
"jobschedule": {authz.PermJobsManage},
81+
"notifications": {authz.PermNotificationsManage},
82+
"security": {authz.PermSettingsRead},
83+
"timeouts": {authz.PermSettingsRead},
84+
"users": {authz.PermUsersList, authz.PermUsersRead},
85+
"webhooks": {authz.PermWebhooksList},
86+
}
87+
7488
// validateProjectsDirectoryValueInternal validates a projects directory value allowing:
7589
// - Unix absolute paths (/...)
7690
// - Windows drive paths (C:/..., C:\...)
@@ -171,7 +185,6 @@ func RegisterSettings(api huma.API, settingsService *services.SettingsService, s
171185
{"BearerAuth": {}},
172186
{"ApiKeyAuth": {}},
173187
},
174-
Middlewares: humamw.RequirePermission(api, authz.PermSettingsRead),
175188
}, h.Search)
176189

177190
huma.Register(api, huma.Operation{
@@ -185,10 +198,53 @@ func RegisterSettings(api huma.API, settingsService *services.SettingsService, s
185198
{"BearerAuth": {}},
186199
{"ApiKeyAuth": {}},
187200
},
188-
Middlewares: humamw.RequirePermission(api, authz.PermSettingsRead),
189201
}, h.GetCategories)
190202
}
191203

204+
func permissionSetAllowsAtAnyScopeInternal(ps *authz.PermissionSet, perm string) bool {
205+
if ps == nil {
206+
return false
207+
}
208+
if ps.Allows(perm, "") {
209+
return true
210+
}
211+
for envID := range ps.PerEnv {
212+
if ps.Allows(perm, envID) {
213+
return true
214+
}
215+
}
216+
return false
217+
}
218+
219+
func canAccessSettingsCategoryInternal(ps *authz.PermissionSet, categoryID string) bool {
220+
if ps == nil {
221+
return false
222+
}
223+
perms, ok := settingsCategoryPermissionsInternal[categoryID]
224+
if !ok {
225+
return false
226+
}
227+
for _, perm := range perms {
228+
if permissionSetAllowsAtAnyScopeInternal(ps, perm) {
229+
return true
230+
}
231+
}
232+
return false
233+
}
234+
235+
func filterSettingsCategoriesInternal(ps *authz.PermissionSet, categories []category.Category) []category.Category {
236+
if ps == nil {
237+
return []category.Category{}
238+
}
239+
filtered := make([]category.Category, 0, len(categories))
240+
for _, cat := range categories {
241+
if canAccessSettingsCategoryInternal(ps, cat.ID) {
242+
filtered = append(filtered, cat)
243+
}
244+
}
245+
return filtered
246+
}
247+
192248
func (h *SettingsHandler) appendRuntimeSettingsInternal(settingsDto []settings.PublicSetting, includeAuthenticatedOnly bool) []settings.PublicSetting {
193249
if !includeAuthenticatedOnly {
194250
return settingsDto
@@ -416,7 +472,10 @@ func (h *SettingsHandler) Search(ctx context.Context, input *SearchSettingsInput
416472
return nil, huma.Error400BadRequest((&common.QueryParameterRequiredError{}).Error())
417473
}
418474

475+
ps, _ := humamw.PermissionsFromContext(ctx)
419476
results := h.settingsSearchService.Search(input.Body.Query)
477+
results.Results = filterSettingsCategoriesInternal(ps, results.Results)
478+
results.Count = len(results.Results)
420479
return &SearchSettingsOutput{Body: results}, nil
421480
}
422481

@@ -426,6 +485,7 @@ func (h *SettingsHandler) GetCategories(ctx context.Context, input *struct{}) (*
426485
return nil, huma.Error500InternalServerError("service not available")
427486
}
428487

429-
categories := h.settingsSearchService.GetSettingsCategories()
488+
ps, _ := humamw.PermissionsFromContext(ctx)
489+
categories := filterSettingsCategoriesInternal(ps, h.settingsSearchService.GetSettingsCategories())
430490
return &GetCategoriesOutput{Body: categories}, nil
431491
}

backend/internal/services/user_service.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ func (s *UserService) toUserResponseDtoInternal(ctx context.Context, u models.Us
477477
}
478478
}
479479
if ps, err := s.roleService.ResolvePermissions(ctx, &u); err == nil && ps != nil {
480+
dto.IsGlobalAdmin = ps.IsGlobalAdmin()
480481
dto.PermissionsByEnv = permissionSetToMap(ps)
481482
}
482483
return dto

cli/go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
github.com/spf13/cobra v1.10.2
1717
github.com/spf13/pflag v1.0.10
1818
github.com/spf13/viper v1.21.0
19+
github.com/stretchr/testify v1.11.1
1920
go.withmatt.com/size v0.0.0-20250220224316-11aee5773e67
2021
)
2122

@@ -35,6 +36,7 @@ require (
3536
github.com/compose-spec/compose-go/v2 v2.11.0 // indirect
3637
github.com/containerd/errdefs v1.0.0 // indirect
3738
github.com/containerd/errdefs/pkg v0.3.0 // indirect
39+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
3840
github.com/distribution/reference v0.6.0 // indirect
3941
github.com/docker/go-connections v0.7.0 // indirect
4042
github.com/docker/go-units v0.5.0 // indirect
@@ -59,6 +61,7 @@ require (
5961
github.com/opencontainers/go-digest v1.0.0 // indirect
6062
github.com/opencontainers/image-spec v1.1.1 // indirect
6163
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
64+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
6265
github.com/rivo/uniseg v0.4.7 // indirect
6366
github.com/sagikazarmark/locafero v0.12.0 // indirect
6467
github.com/sahilm/fuzzy v0.1.2 // indirect
@@ -79,4 +82,5 @@ require (
7982
golang.org/x/sync v0.20.0 // indirect
8083
golang.org/x/sys v0.45.0 // indirect
8184
golang.org/x/text v0.37.0 // indirect
85+
gopkg.in/yaml.v3 v3.0.1 // indirect
8286
)

frontend/src/lib/components/forms/role-assignments-editor.svelte

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import * as Select from '$lib/components/ui/select';
3+
import { Checkbox } from '$lib/components/ui/checkbox';
34
import { ArcaneButton } from '$lib/components/arcane-button/index.js';
45
import StatusBadge from '$lib/components/badges/status-badge.svelte';
56
import {
@@ -33,6 +34,8 @@
3334
...environments.map((env) => ({ id: env.id, name: env.name }))
3435
]);
3536
37+
const quickPresetRoles = $derived(roles.filter((role) => role.id === BUILT_IN_ROLE_EDITOR || role.id === BUILT_IN_ROLE_ADMIN));
38+
3639
function getRoleVariant(roleId: string) {
3740
switch (roleId) {
3841
case BUILT_IN_ROLE_ADMIN:
@@ -87,9 +90,54 @@
8790
function roleSelectedLabel(value: string): string {
8891
return roles.find((r) => r.id === value)?.name ?? m.common_select_option();
8992
}
93+
94+
function hasGlobalRoleAssignment(roleId: string): boolean {
95+
return assignments.some((assignment) => assignment.roleId === roleId && !assignment.environmentId);
96+
}
97+
98+
function toggleQuickPreset(roleId: string, checked: boolean) {
99+
if (disabled) return;
100+
const conflictingRoleId =
101+
roleId === BUILT_IN_ROLE_ADMIN ? BUILT_IN_ROLE_EDITOR : roleId === BUILT_IN_ROLE_EDITOR ? BUILT_IN_ROLE_ADMIN : '';
102+
let next = assignments;
103+
if (checked) {
104+
if (conflictingRoleId) {
105+
next = next.filter((assignment) => !(assignment.roleId === conflictingRoleId && !assignment.environmentId));
106+
}
107+
if (!next.some((assignment) => assignment.roleId === roleId && !assignment.environmentId)) {
108+
next = [...next, { roleId, environmentId: undefined }];
109+
}
110+
} else {
111+
next = next.filter((assignment) => !(assignment.roleId === roleId && !assignment.environmentId));
112+
}
113+
assignments = next;
114+
}
90115
</script>
91116

92117
<div class="space-y-3">
118+
{#if quickPresetRoles.length > 0}
119+
<div class="space-y-2 rounded-md border p-3">
120+
{#each quickPresetRoles as role (role.id)}
121+
<label class="flex cursor-pointer items-start gap-3 rounded-md p-2 hover:bg-accent/40">
122+
<Checkbox
123+
checked={hasGlobalRoleAssignment(role.id)}
124+
{disabled}
125+
onCheckedChange={(checked) => toggleQuickPreset(role.id, checked === true)}
126+
/>
127+
<div class="flex flex-col gap-1">
128+
<div class="flex items-center gap-2">
129+
<StatusBadge text={role.name} variant={getRoleVariant(role.id)} size="sm" minWidth="none" />
130+
<span class="text-xs text-muted-foreground">{m.users_role_assignments_scope_global()}</span>
131+
</div>
132+
{#if role.description}
133+
<span class="text-muted-foreground text-xs">{role.description}</span>
134+
{/if}
135+
</div>
136+
</label>
137+
{/each}
138+
</div>
139+
{/if}
140+
93141
{#if assignments.length === 0}
94142
<p class="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
95143
{m.users_role_assignments_description()}

0 commit comments

Comments
 (0)