Skip to content

Commit a2f4a0a

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 8f4b99b commit a2f4a0a

File tree

16 files changed

+238
-7
lines changed

16 files changed

+238
-7
lines changed

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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,29 @@ func (s APIKeyScopes) Expand() (rbac.Scope, error) {
203203
}
204204
}
205205

206+
// De-duplicate permissions across Site/Org/User
207+
dedup := func(in []rbac.Permission) []rbac.Permission {
208+
if len(in) == 0 {
209+
return in
210+
}
211+
seen := make(map[string]struct{}, len(in))
212+
out := make([]rbac.Permission, 0, len(in))
213+
for _, p := range in {
214+
key := p.ResourceType + "\x00" + string(p.Action) + "\x00" + strconv.FormatBool(p.Negate)
215+
if _, ok := seen[key]; ok {
216+
continue
217+
}
218+
seen[key] = struct{}{}
219+
out = append(out, p)
220+
}
221+
return out
222+
}
223+
merged.Site = dedup(merged.Site)
224+
for orgID, perms := range merged.Org {
225+
merged.Org[orgID] = dedup(perms)
226+
}
227+
merged.User = dedup(merged.User)
228+
206229
if allowAll || len(allowSet) == 0 {
207230
merged.AllowIDList = []rbac.AllowListElement{rbac.AllowListAll()}
208231
} 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/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
}

coderd/rbac/scopes_catalog.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ var externalLowLevel = map[ScopeName]struct{}{
5252
"user_secret:*": {},
5353
}
5454

55+
// Public composite coder:* scopes exposed to users.
56+
var externalComposite = map[ScopeName]struct{}{
57+
"coder:workspaces.create": {},
58+
"coder:workspaces.operate": {},
59+
"coder:workspaces.delete": {},
60+
"coder:workspaces.access": {},
61+
"coder:templates.build": {},
62+
"coder:templates.author": {},
63+
"coder:apikeys.manage_self": {},
64+
}
65+
5566
// IsExternalScope returns true if the scope is public, including the
5667
// `all` and `application_connect` special scopes and the curated
5768
// low-level resource:action scopes.
@@ -64,15 +75,18 @@ func IsExternalScope(name ScopeName) bool {
6475
if _, ok := externalLowLevel[name]; ok {
6576
return true
6677
}
78+
if _, ok := externalComposite[name]; ok {
79+
return true
80+
}
6781

6882
return false
6983
}
7084

71-
// ExternalScopeNames returns a sorted list of all public scopes, which includes
72-
// the `all` and `application_connect` special scopes and the curated public
73-
// low-level names.
85+
// ExternalScopeNames returns a sorted list of all public scopes, which
86+
// includes the `all` and `application_connect` special scopes, curated
87+
// low-level resource:action names, and curated composite coder:* scopes.
7488
func ExternalScopeNames() []string {
75-
names := make([]string, 0, len(externalLowLevel)+2)
89+
names := make([]string, 0, len(externalLowLevel)+len(externalComposite)+2)
7690
names = append(names, string(ScopeAll))
7791
names = append(names, string(ScopeApplicationConnect))
7892

@@ -83,6 +97,11 @@ func ExternalScopeNames() []string {
8397
}
8498
}
8599

100+
// curated composite names
101+
for name := range externalComposite {
102+
names = append(names, string(name))
103+
}
104+
86105
sort.Slice(names, func(i, j int) bool { return strings.Compare(names[i], names[j]) < 0 })
87106
return names
88107
}

coderd/rbac/scopes_catalog_internal_test.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package rbac
22

33
import (
44
"sort"
5+
"strings"
56
"testing"
67

78
"github.com/stretchr/testify/require"
@@ -18,7 +19,7 @@ func TestExternalScopeNames(t *testing.T) {
1819
sort.Strings(sorted)
1920
require.Equal(t, sorted, names)
2021

21-
// Ensure each entry parses and expands to site-only
22+
// Ensure each entry expands to site-only
2223
for _, name := range names {
2324
// Skip `all` and `application_connect` since they do not
2425
// expand into a low level scope.
@@ -27,6 +28,17 @@ func TestExternalScopeNames(t *testing.T) {
2728
continue
2829
}
2930

31+
// Composite coder:* scopes expand to one or more site permissions.
32+
if strings.HasPrefix(name, "coder:") {
33+
s, err := ScopeName(name).Expand()
34+
require.NoErrorf(t, err, "catalog entry should expand: %s", name)
35+
require.NotEmpty(t, s.Site)
36+
require.Empty(t, s.Org)
37+
require.Empty(t, s.User)
38+
continue
39+
}
40+
41+
// Low-level scopes must parse to a single permission.
3042
res, act, ok := parseLowLevelScope(ScopeName(name))
3143
require.Truef(t, ok, "catalog entry should parse: %s", name)
3244

0 commit comments

Comments
 (0)