feat: implement composite API key scopes for workspaces and templates (#19945)

# Add Composite API Key Scopes

This PR adds high-level composite API key scopes to simplify token creation with common permission sets:

- `coder:workspaces.create` - Create and update workspaces
- `coder:workspaces.operate` - Read and update workspaces
- `coder:workspaces.delete` - Read and delete workspaces
- `coder:workspaces.access` - Read, SSH, and connect to workspace applications
- `coder:templates.build` - Read templates and create/read files
- `coder:templates.author` - Full template management with insights
- `coder:apikeys.manage_self` - Manage your own API keys

These composite scopes are persisted in the database and expanded during authorization, providing a more intuitive way to grant permissions compared to the granular resource:action scopes.
This commit is contained in:
Thomas Kosiewski
2025-09-29 13:17:08 +02:00
committed by GitHub
parent 860bcd4d91
commit 79126ab6c7
18 changed files with 267 additions and 7 deletions
+14
View File
@@ -11529,7 +11529,14 @@ const docTemplate = `{
"api_key:read",
"api_key:update",
"coder:all",
"coder:apikeys.manage_self",
"coder:application_connect",
"coder:templates.author",
"coder:templates.build",
"coder:workspaces.access",
"coder:workspaces.create",
"coder:workspaces.delete",
"coder:workspaces.operate",
"file:*",
"file:create",
"file:read",
@@ -11565,7 +11572,14 @@ const docTemplate = `{
"APIKeyScopeApiKeyRead",
"APIKeyScopeApiKeyUpdate",
"APIKeyScopeCoderAll",
"APIKeyScopeCoderApikeysManageSelf",
"APIKeyScopeCoderApplicationConnect",
"APIKeyScopeCoderTemplatesAuthor",
"APIKeyScopeCoderTemplatesBuild",
"APIKeyScopeCoderWorkspacesAccess",
"APIKeyScopeCoderWorkspacesCreate",
"APIKeyScopeCoderWorkspacesDelete",
"APIKeyScopeCoderWorkspacesOperate",
"APIKeyScopeFileAll",
"APIKeyScopeFileCreate",
"APIKeyScopeFileRead",
+14
View File
@@ -10245,7 +10245,14 @@
"api_key:read",
"api_key:update",
"coder:all",
"coder:apikeys.manage_self",
"coder:application_connect",
"coder:templates.author",
"coder:templates.build",
"coder:workspaces.access",
"coder:workspaces.create",
"coder:workspaces.delete",
"coder:workspaces.operate",
"file:*",
"file:create",
"file:read",
@@ -10281,7 +10288,14 @@
"APIKeyScopeApiKeyRead",
"APIKeyScopeApiKeyUpdate",
"APIKeyScopeCoderAll",
"APIKeyScopeCoderApikeysManageSelf",
"APIKeyScopeCoderApplicationConnect",
"APIKeyScopeCoderTemplatesAuthor",
"APIKeyScopeCoderTemplatesBuild",
"APIKeyScopeCoderWorkspacesAccess",
"APIKeyScopeCoderWorkspacesCreate",
"APIKeyScopeCoderWorkspacesDelete",
"APIKeyScopeCoderWorkspacesOperate",
"APIKeyScopeFileAll",
"APIKeyScopeFileCreate",
"APIKeyScopeFileRead",
+8 -1
View File
@@ -150,7 +150,14 @@ CREATE TYPE api_key_scope AS ENUM (
'workspace_proxy:create',
'workspace_proxy:delete',
'workspace_proxy:read',
'workspace_proxy:update'
'workspace_proxy:update',
'coder:workspaces.create',
'coder:workspaces.operate',
'coder:workspaces.delete',
'coder:workspaces.access',
'coder:templates.build',
'coder:templates.author',
'coder:apikeys.manage_self'
);
CREATE TYPE app_sharing_level AS ENUM (
@@ -0,0 +1,3 @@
-- No-op: keep enum values to avoid dependency churn.
-- If strict removal is required, create a new enum type without these values,
-- cast columns, drop the old type, and rename.
@@ -0,0 +1,9 @@
-- Add high-level composite coder:* API key scopes
-- These values are persisted so that tokens can store coder:* names directly.
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:workspaces.create';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:workspaces.operate';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:workspaces.delete';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:workspaces.access';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:templates.build';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:templates.author';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'coder:apikeys.manage_self';
+7
View File
@@ -203,6 +203,13 @@ func (s APIKeyScopes) Expand() (rbac.Scope, error) {
}
}
// De-duplicate permissions across Site/Org/User
merged.Site = rbac.DeduplicatePermissions(merged.Site)
for orgID, perms := range merged.Org {
merged.Org[orgID] = rbac.DeduplicatePermissions(perms)
}
merged.User = rbac.DeduplicatePermissions(merged.User)
if allowAll || len(allowSet) == 0 {
merged.AllowIDList = []rbac.AllowListElement{rbac.AllowListAll()}
} else {
+22 -1
View File
@@ -159,6 +159,13 @@ const (
ApiKeyScopeWorkspaceProxyDelete APIKeyScope = "workspace_proxy:delete"
ApiKeyScopeWorkspaceProxyRead APIKeyScope = "workspace_proxy:read"
ApiKeyScopeWorkspaceProxyUpdate APIKeyScope = "workspace_proxy:update"
ApiKeyScopeCoderWorkspacescreate APIKeyScope = "coder:workspaces.create"
ApiKeyScopeCoderWorkspacesoperate APIKeyScope = "coder:workspaces.operate"
ApiKeyScopeCoderWorkspacesdelete APIKeyScope = "coder:workspaces.delete"
ApiKeyScopeCoderWorkspacesaccess APIKeyScope = "coder:workspaces.access"
ApiKeyScopeCoderTemplatesbuild APIKeyScope = "coder:templates.build"
ApiKeyScopeCoderTemplatesauthor APIKeyScope = "coder:templates.author"
ApiKeyScopeCoderApikeysmanageSelf APIKeyScope = "coder:apikeys.manage_self"
)
func (e *APIKeyScope) Scan(src interface{}) error {
@@ -337,7 +344,14 @@ func (e APIKeyScope) Valid() bool {
ApiKeyScopeWorkspaceProxyCreate,
ApiKeyScopeWorkspaceProxyDelete,
ApiKeyScopeWorkspaceProxyRead,
ApiKeyScopeWorkspaceProxyUpdate:
ApiKeyScopeWorkspaceProxyUpdate,
ApiKeyScopeCoderWorkspacescreate,
ApiKeyScopeCoderWorkspacesoperate,
ApiKeyScopeCoderWorkspacesdelete,
ApiKeyScopeCoderWorkspacesaccess,
ApiKeyScopeCoderTemplatesbuild,
ApiKeyScopeCoderTemplatesauthor,
ApiKeyScopeCoderApikeysmanageSelf:
return true
}
return false
@@ -485,6 +499,13 @@ func AllAPIKeyScopeValues() []APIKeyScope {
ApiKeyScopeWorkspaceProxyDelete,
ApiKeyScopeWorkspaceProxyRead,
ApiKeyScopeWorkspaceProxyUpdate,
ApiKeyScopeCoderWorkspacescreate,
ApiKeyScopeCoderWorkspacesoperate,
ApiKeyScopeCoderWorkspacesdelete,
ApiKeyScopeCoderWorkspacesaccess,
ApiKeyScopeCoderTemplatesbuild,
ApiKeyScopeCoderTemplatesauthor,
ApiKeyScopeCoderApikeysmanageSelf,
}
}
+20
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"sort"
"strconv"
"strings"
"github.com/google/uuid"
@@ -863,3 +864,22 @@ func Permissions(perms map[string][]policy.Action) []Permission {
})
return list
}
// DeduplicatePermissions removes duplicate Permission entries while preserving
// the original order of the first occurrence for deterministic evaluation.
func DeduplicatePermissions(perms []Permission) []Permission {
if len(perms) == 0 {
return perms
}
seen := make(map[string]struct{}, len(perms))
deduped := make([]Permission, 0, len(perms))
for _, perm := range perms {
key := perm.ResourceType + "\x00" + string(perm.Action) + "\x00" + strconv.FormatBool(perm.Negate)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
deduped = append(deduped, perm)
}
return deduped
}
+21
View File
@@ -249,6 +249,27 @@ func TestRoleByName(t *testing.T) {
})
}
func TestDeduplicatePermissions(t *testing.T) {
t.Parallel()
perms := []Permission{
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead},
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead},
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionUpdate},
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead, Negate: true},
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead, Negate: true},
}
got := DeduplicatePermissions(perms)
want := []Permission{
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead},
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionUpdate},
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead, Negate: true},
}
require.Equal(t, want, got)
}
// SameAs compares 2 roles for equality.
func equalRoles(t *testing.T, a, b Role) {
require.Equal(t, a.Identifier, b.Identifier, "role names")
+64
View File
@@ -3,6 +3,7 @@ package rbac
import (
"fmt"
"slices"
"sort"
"strings"
"github.com/google/uuid"
@@ -120,6 +121,56 @@ func BuiltinScopeNames() []ScopeName {
return names
}
// Composite coder:* scopes expand to multiple low-level resource:action permissions
// at Site level. These names are persisted in the DB and expanded during
// authorization.
var compositePerms = map[ScopeName]map[string][]policy.Action{
"coder:workspaces.create": {
ResourceTemplate.Type: {policy.ActionRead, policy.ActionUse},
ResourceWorkspace.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionRead},
},
"coder:workspaces.operate": {
ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate},
},
"coder:workspaces.delete": {
ResourceWorkspace.Type: {policy.ActionRead, policy.ActionDelete},
},
"coder:workspaces.access": {
ResourceWorkspace.Type: {policy.ActionRead, policy.ActionSSH, policy.ActionApplicationConnect},
},
"coder:templates.build": {
ResourceTemplate.Type: {policy.ActionRead},
ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
"provisioner_jobs": {policy.ActionRead},
},
"coder:templates.author": {
ResourceTemplate.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete, policy.ActionViewInsights},
ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
},
"coder:apikeys.manage_self": {
ResourceApiKey.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete},
},
}
// CompositeSitePermissions returns the site-level Permission list for a coder:* scope.
func CompositeSitePermissions(name ScopeName) ([]Permission, bool) {
perms, ok := compositePerms[name]
if !ok {
return nil, false
}
return Permissions(perms), true
}
// CompositeScopeNames lists all high-level coder:* names in sorted order.
func CompositeScopeNames() []string {
out := make([]string, 0, len(compositePerms))
for k := range compositePerms {
out = append(out, string(k))
}
sort.Strings(out)
return out
}
type ExpandableScope interface {
Expand() (Scope, error)
// Name is for logging and tracing purposes, we want to know the human
@@ -175,6 +226,19 @@ func ExpandScope(scope ScopeName) (Scope, error) {
if role, ok := builtinScopes[scope]; ok {
return role, nil
}
if site, ok := CompositeSitePermissions(scope); ok {
return Scope{
Role: Role{
Identifier: RoleIdentifier{Name: fmt.Sprintf("Scope_%s", scope)},
DisplayName: string(scope),
Site: site,
Org: map[string][]Permission{},
User: []Permission{},
},
// Composites are site-level; allow-list empty by default
AllowIDList: []AllowListElement{},
}, nil
}
if res, act, ok := parseLowLevelScope(scope); ok {
return expandLowLevel(res, act), nil
}
+23 -4
View File
@@ -52,6 +52,17 @@ var externalLowLevel = map[ScopeName]struct{}{
"user_secret:*": {},
}
// Public composite coder:* scopes exposed to users.
var externalComposite = map[ScopeName]struct{}{
"coder:workspaces.create": {},
"coder:workspaces.operate": {},
"coder:workspaces.delete": {},
"coder:workspaces.access": {},
"coder:templates.build": {},
"coder:templates.author": {},
"coder:apikeys.manage_self": {},
}
// IsExternalScope returns true if the scope is public, including the
// `all` and `application_connect` special scopes and the curated
// low-level resource:action scopes.
@@ -64,15 +75,18 @@ func IsExternalScope(name ScopeName) bool {
if _, ok := externalLowLevel[name]; ok {
return true
}
if _, ok := externalComposite[name]; ok {
return true
}
return false
}
// ExternalScopeNames returns a sorted list of all public scopes, which includes
// the `all` and `application_connect` special scopes and the curated public
// low-level names.
// ExternalScopeNames returns a sorted list of all public scopes, which
// includes the `all` and `application_connect` special scopes, curated
// low-level resource:action names, and curated composite coder:* scopes.
func ExternalScopeNames() []string {
names := make([]string, 0, len(externalLowLevel)+2)
names := make([]string, 0, len(externalLowLevel)+len(externalComposite)+2)
names = append(names, string(ScopeAll))
names = append(names, string(ScopeApplicationConnect))
@@ -83,6 +97,11 @@ func ExternalScopeNames() []string {
}
}
// curated composite names
for name := range externalComposite {
names = append(names, string(name))
}
sort.Slice(names, func(i, j int) bool { return strings.Compare(names[i], names[j]) < 0 })
return names
}
+17 -1
View File
@@ -2,6 +2,7 @@ package rbac
import (
"sort"
"strings"
"testing"
"github.com/stretchr/testify/require"
@@ -18,7 +19,7 @@ func TestExternalScopeNames(t *testing.T) {
sort.Strings(sorted)
require.Equal(t, sorted, names)
// Ensure each entry parses and expands to site-only
// Ensure each entry expands to site-only
for _, name := range names {
// Skip `all` and `application_connect` since they do not
// expand into a low level scope.
@@ -27,6 +28,20 @@ func TestExternalScopeNames(t *testing.T) {
continue
}
// Composite coder:* scopes expand to one or more site permissions.
if strings.HasPrefix(name, "coder:") {
s, err := ScopeName(name).Expand()
require.NoErrorf(t, err, "catalog entry should expand: %s", name)
require.NotEmpty(t, s.Site)
expected, ok := CompositeSitePermissions(ScopeName(name))
require.Truef(t, ok, "expected composite scope definition: %s", name)
require.ElementsMatchf(t, expected, s.Site, "unexpected expanded permissions for %s", name)
require.Empty(t, s.Org)
require.Empty(t, s.User)
continue
}
// Low-level scopes must parse to a single permission.
res, act, ok := parseLowLevelScope(ScopeName(name))
require.Truef(t, ok, "catalog entry should parse: %s", name)
@@ -46,6 +61,7 @@ func TestIsExternalScope(t *testing.T) {
require.True(t, IsExternalScope("workspace:read"))
require.True(t, IsExternalScope("template:use"))
require.True(t, IsExternalScope("workspace:*"))
require.True(t, IsExternalScope("coder:workspaces.create"))
require.False(t, IsExternalScope("debug_info:read")) // internal-only
require.False(t, IsExternalScope("unknown:read"))
}
+14
View File
@@ -12,7 +12,14 @@ const (
APIKeyScopeApiKeyRead APIKeyScope = "api_key:read"
APIKeyScopeApiKeyUpdate APIKeyScope = "api_key:update"
APIKeyScopeCoderAll APIKeyScope = "coder:all"
APIKeyScopeCoderApikeysManageSelf APIKeyScope = "coder:apikeys.manage_self"
APIKeyScopeCoderApplicationConnect APIKeyScope = "coder:application_connect"
APIKeyScopeCoderTemplatesAuthor APIKeyScope = "coder:templates.author"
APIKeyScopeCoderTemplatesBuild APIKeyScope = "coder:templates.build"
APIKeyScopeCoderWorkspacesAccess APIKeyScope = "coder:workspaces.access"
APIKeyScopeCoderWorkspacesCreate APIKeyScope = "coder:workspaces.create"
APIKeyScopeCoderWorkspacesDelete APIKeyScope = "coder:workspaces.delete"
APIKeyScopeCoderWorkspacesOperate APIKeyScope = "coder:workspaces.operate"
APIKeyScopeFileAll APIKeyScope = "file:*"
APIKeyScopeFileCreate APIKeyScope = "file:create"
APIKeyScopeFileRead APIKeyScope = "file:read"
@@ -48,7 +55,14 @@ var PublicAPIKeyScopes = []APIKeyScope{
APIKeyScopeApiKeyRead,
APIKeyScopeApiKeyUpdate,
APIKeyScopeCoderAll,
APIKeyScopeCoderApikeysManageSelf,
APIKeyScopeCoderApplicationConnect,
APIKeyScopeCoderTemplatesAuthor,
APIKeyScopeCoderTemplatesBuild,
APIKeyScopeCoderWorkspacesAccess,
APIKeyScopeCoderWorkspacesCreate,
APIKeyScopeCoderWorkspacesDelete,
APIKeyScopeCoderWorkspacesOperate,
APIKeyScopeFileAll,
APIKeyScopeFileCreate,
APIKeyScopeFileRead,
+7
View File
@@ -721,7 +721,14 @@
| `api_key:read` |
| `api_key:update` |
| `coder:all` |
| `coder:apikeys.manage_self` |
| `coder:application_connect` |
| `coder:templates.author` |
| `coder:templates.build` |
| `coder:workspaces.access` |
| `coder:workspaces.create` |
| `coder:workspaces.delete` |
| `coder:workspaces.operate` |
| `file:*` |
| `file:create` |
| `file:read` |
+1
View File
@@ -112,6 +112,7 @@ func pascal(s string) string {
s = strings.ReplaceAll(s, "_", " ")
s = strings.ReplaceAll(s, "-", " ")
s = strings.ReplaceAll(s, ":", " ")
s = strings.ReplaceAll(s, ".", " ")
words := strings.Fields(s)
for i := range words {
words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
+6
View File
@@ -11,6 +11,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
)
@@ -60,6 +61,7 @@ func main() {
// expectedFromRBAC returns the set of <resource>:<action> pairs derived from RBACPermissions.
func expectedFromRBAC() map[string]struct{} {
want := make(map[string]struct{})
// Low-level <resource>:<action>
for resource, def := range policy.RBACPermissions {
if resource == policy.WildcardSymbol {
// Ignore wildcard entry; it has no concrete <resource>:<action> pairs.
@@ -70,6 +72,10 @@ func expectedFromRBAC() map[string]struct{} {
want[key] = struct{}{}
}
}
// Composite coder:* names
for _, n := range rbac.CompositeScopeNames() {
want[n] = struct{}{}
}
return want
}
@@ -4,6 +4,7 @@ import (
"fmt"
"sort"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
)
@@ -18,6 +19,8 @@ func main() {
vals = append(vals, fmt.Sprintf("%s:%s", resource, action))
}
}
// Include composite coder:* scopes as first-class enum values
vals = append(vals, rbac.CompositeScopeNames()...)
sort.Strings(vals)
for _, v := range vals {
if _, ok := seen[v]; ok {
+14
View File
@@ -120,7 +120,14 @@ export type APIKeyScope =
| "api_key:update"
| "application_connect"
| "coder:all"
| "coder:apikeys.manage_self"
| "coder:application_connect"
| "coder:templates.author"
| "coder:templates.build"
| "coder:workspaces.access"
| "coder:workspaces.create"
| "coder:workspaces.delete"
| "coder:workspaces.operate"
| "file:*"
| "file:create"
| "file:read"
@@ -156,7 +163,14 @@ export const APIKeyScopes: APIKeyScope[] = [
"api_key:update",
"application_connect",
"coder:all",
"coder:apikeys.manage_self",
"coder:application_connect",
"coder:templates.author",
"coder:templates.build",
"coder:workspaces.access",
"coder:workspaces.create",
"coder:workspaces.delete",
"coder:workspaces.operate",
"file:*",
"file:create",
"file:read",