Skip to content

Commit ff05d77

Browse files
committed
feat: add composite coder:* API key scopes for better UX
Add high-level composite scopes that expand to multiple low-level permissions: - coder:workspaces.create - Template read/use + workspace CRUD - coder:workspaces.operate - Workspace read/update - coder:workspaces.delete - Workspace read/delete - coder:workspaces.access - Workspace read/SSH/app connect - coder:templates.build - Template read + file ops + provisioner jobs - coder:templates.author - Full template management + insights - coder:apikeys.manage_self - Self API key management These composite scopes provide intuitive high-level permissions while maintaining granular control through existing low-level scopes. Database enum values are persisted to enable storing composite names directly in tokens.
1 parent e257d2e commit ff05d77

18 files changed

Lines changed: 267 additions & 7 deletions

File tree

coderd/apidoc/docs.go

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dump.sql

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- No-op: keep enum values to avoid dependency churn.
2+
-- If strict removal is required, create a new enum type without these values,
3+
-- cast columns, drop the old type, and rename.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- Add high-level composite coder:* API key scopes
2+
-- These values are persisted so that tokens can store coder:* names directly.
3+
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:workspaces.create';
4+
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:workspaces.operate';
5+
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:workspaces.delete';
6+
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:workspaces.access';
7+
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:templates.build';
8+
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:templates.author';
9+
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:apikeys.manage_self';

coderd/database/modelmethods.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,13 @@ func (s APIKeyScopes) Expand() (rbac.Scope, error) {
203203
}
204204
}
205205

206+
// De-duplicate permissions across Site/Org/User
207+
merged.Site = rbac.DeduplicatePermissions(merged.Site)
208+
for orgID, perms := range merged.Org {
209+
merged.Org[orgID] = rbac.DeduplicatePermissions(perms)
210+
}
211+
merged.User = rbac.DeduplicatePermissions(merged.User)
212+
206213
if allowAll || len(allowSet) == 0 {
207214
merged.AllowIDList = []rbac.AllowListElement{rbac.AllowListAll()}
208215
} else {

coderd/database/models.go

Lines changed: 22 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/rbac/roles.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"errors"
66
"sort"
7+
"strconv"
78
"strings"
89

910
"github.com/google/uuid"
@@ -863,3 +864,22 @@ func Permissions(perms map[string][]policy.Action) []Permission {
863864
})
864865
return list
865866
}
867+
868+
// DeduplicatePermissions removes duplicate Permission entries while preserving
869+
// the original order of the first occurrence for deterministic evaluation.
870+
func DeduplicatePermissions(perms []Permission) []Permission {
871+
if len(perms) == 0 {
872+
return perms
873+
}
874+
seen := make(map[string]struct{}, len(perms))
875+
deduped := make([]Permission, 0, len(perms))
876+
for _, perm := range perms {
877+
key := perm.ResourceType + "\x00" + string(perm.Action) + "\x00" + strconv.FormatBool(perm.Negate)
878+
if _, ok := seen[key]; ok {
879+
continue
880+
}
881+
seen[key] = struct{}{}
882+
deduped = append(deduped, perm)
883+
}
884+
return deduped
885+
}

coderd/rbac/roles_internal_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,27 @@ func TestRoleByName(t *testing.T) {
249249
})
250250
}
251251

252+
func TestDeduplicatePermissions(t *testing.T) {
253+
t.Parallel()
254+
255+
perms := []Permission{
256+
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead},
257+
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead},
258+
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionUpdate},
259+
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead, Negate: true},
260+
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead, Negate: true},
261+
}
262+
263+
got := DeduplicatePermissions(perms)
264+
want := []Permission{
265+
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead},
266+
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionUpdate},
267+
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead, Negate: true},
268+
}
269+
270+
require.Equal(t, want, got)
271+
}
272+
252273
// SameAs compares 2 roles for equality.
253274
func equalRoles(t *testing.T, a, b Role) {
254275
require.Equal(t, a.Identifier, b.Identifier, "role names")

coderd/rbac/scopes.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package rbac
33
import (
44
"fmt"
55
"slices"
6+
"sort"
67
"strings"
78

89
"github.com/google/uuid"
@@ -120,6 +121,56 @@ func BuiltinScopeNames() []ScopeName {
120121
return names
121122
}
122123

124+
// Composite coder:* scopes expand to multiple low-level resource:action permissions
125+
// at Site level. These names are persisted in the DB and expanded during
126+
// authorization.
127+
var compositePerms = map[ScopeName]map[string][]policy.Action{
128+
"coder:workspaces.create": {
129+
ResourceTemplate.Type: {policy.ActionRead, policy.ActionUse},
130+
ResourceWorkspace.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionRead},
131+
},
132+
"coder:workspaces.operate": {
133+
ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate},
134+
},
135+
"coder:workspaces.delete": {
136+
ResourceWorkspace.Type: {policy.ActionRead, policy.ActionDelete},
137+
},
138+
"coder:workspaces.access": {
139+
ResourceWorkspace.Type: {policy.ActionRead, policy.ActionSSH, policy.ActionApplicationConnect},
140+
},
141+
"coder:templates.build": {
142+
ResourceTemplate.Type: {policy.ActionRead},
143+
ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
144+
"provisioner_jobs": {policy.ActionRead},
145+
},
146+
"coder:templates.author": {
147+
ResourceTemplate.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete, policy.ActionViewInsights},
148+
ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
149+
},
150+
"coder:apikeys.manage_self": {
151+
ResourceApiKey.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete},
152+
},
153+
}
154+
155+
// CompositeSitePermissions returns the site-level Permission list for a coder:* scope.
156+
func CompositeSitePermissions(name ScopeName) ([]Permission, bool) {
157+
perms, ok := compositePerms[name]
158+
if !ok {
159+
return nil, false
160+
}
161+
return Permissions(perms), true
162+
}
163+
164+
// CompositeScopeNames lists all high-level coder:* names in sorted order.
165+
func CompositeScopeNames() []string {
166+
out := make([]string, 0, len(compositePerms))
167+
for k := range compositePerms {
168+
out = append(out, string(k))
169+
}
170+
sort.Strings(out)
171+
return out
172+
}
173+
123174
type ExpandableScope interface {
124175
Expand() (Scope, error)
125176
// Name is for logging and tracing purposes, we want to know the human
@@ -175,6 +226,19 @@ func ExpandScope(scope ScopeName) (Scope, error) {
175226
if role, ok := builtinScopes[scope]; ok {
176227
return role, nil
177228
}
229+
if site, ok := CompositeSitePermissions(scope); ok {
230+
return Scope{
231+
Role: Role{
232+
Identifier: RoleIdentifier{Name: fmt.Sprintf("Scope_%s", scope)},
233+
DisplayName: string(scope),
234+
Site: site,
235+
Org: map[string][]Permission{},
236+
User: []Permission{},
237+
},
238+
// Composites are site-level; allow-list empty by default
239+
AllowIDList: []AllowListElement{},
240+
}, nil
241+
}
178242
if res, act, ok := parseLowLevelScope(scope); ok {
179243
return expandLowLevel(res, act), nil
180244
}

0 commit comments

Comments
 (0)