Skip to content

Commit 47c92ad

Browse files
authored
feat: add public RBAC scope catalog for user-requestable permissions (#19913)
# Add a curated catalog of public RBAC scopes This PR introduces a curated catalog of public RBAC scopes that are exposed to users. It adds: - A `publicLowLevel` map in `scopes_catalog.go` that defines which resource:action pairs are user-requestable - `IsPublicLowLevel()` function to check if a scope is in the public catalog - `PublicLowLevelScopeNames()` function that returns a sorted list of public scopes - Tests to verify the catalog entries are valid and properly sorted - Updated documentation in the check-scopes README to clarify that public scopes should be added to this catalog This change helps distinguish between internal-only scopes and those that should be exposed to users in the API.
1 parent eb55f0a commit 47c92ad

File tree

5 files changed

+146
-2
lines changed

5 files changed

+146
-2
lines changed

coderd/rbac/scopes.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,11 @@ func parseLowLevelScope(name ScopeName) (resource string, action policy.Action,
205205
if !exists {
206206
return "", "", false
207207
}
208+
209+
if act == policy.WildcardSymbol {
210+
return res, policy.WildcardSymbol, true
211+
}
212+
208213
if _, exists := def.Actions[policy.Action(act)]; !exists {
209214
return "", "", false
210215
}

coderd/rbac/scopes_catalog.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package rbac
2+
3+
import (
4+
"sort"
5+
"strings"
6+
)
7+
8+
// externalLowLevel is the curated set of low-level scope names exposed to users.
9+
// Any valid resource:action pair not in this set is considered internal-only
10+
// and must not be user-requestable.
11+
var externalLowLevel = map[ScopeName]struct{}{
12+
// Workspaces
13+
"workspace:read": {},
14+
"workspace:create": {},
15+
"workspace:update": {},
16+
"workspace:delete": {},
17+
"workspace:ssh": {},
18+
"workspace:start": {},
19+
"workspace:stop": {},
20+
"workspace:application_connect": {},
21+
"workspace:*": {},
22+
23+
// Templates
24+
"template:read": {},
25+
"template:create": {},
26+
"template:update": {},
27+
"template:delete": {},
28+
"template:use": {},
29+
"template:*": {},
30+
31+
// API keys (self-management)
32+
"api_key:read": {},
33+
"api_key:create": {},
34+
"api_key:update": {},
35+
"api_key:delete": {},
36+
"api_key:*": {},
37+
38+
// Files
39+
"file:read": {},
40+
"file:create": {},
41+
"file:*": {},
42+
43+
// Users (personal profile only)
44+
"user:read_personal": {},
45+
"user:update_personal": {},
46+
47+
// User secrets
48+
"user_secret:read": {},
49+
"user_secret:create": {},
50+
"user_secret:update": {},
51+
"user_secret:delete": {},
52+
"user_secret:*": {},
53+
}
54+
55+
// IsExternalScope returns true if the scope is public, including the
56+
// `all` and `application_connect` special scopes and the curated
57+
// low-level resource:action scopes.
58+
func IsExternalScope(name ScopeName) bool {
59+
switch name {
60+
case ScopeAll, ScopeApplicationConnect:
61+
return true
62+
}
63+
if _, ok := externalLowLevel[name]; ok {
64+
return true
65+
}
66+
67+
return false
68+
}
69+
70+
// ExternalScopeNames returns a sorted list of all public scopes, which includes
71+
// the `all` and `application_connect` special scopes and the curated public
72+
// low-level names.
73+
func ExternalScopeNames() []string {
74+
names := make([]string, 0, len(externalLowLevel)+2)
75+
names = append(names, string(ScopeAll))
76+
names = append(names, string(ScopeApplicationConnect))
77+
78+
// curated low-level names, filtered for validity
79+
for name := range externalLowLevel {
80+
if _, _, ok := parseLowLevelScope(name); ok {
81+
names = append(names, string(name))
82+
}
83+
}
84+
85+
sort.Slice(names, func(i, j int) bool { return strings.Compare(names[i], names[j]) < 0 })
86+
return names
87+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package rbac
2+
3+
import (
4+
"sort"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestExternalScopeNames(t *testing.T) {
11+
t.Parallel()
12+
13+
names := ExternalScopeNames()
14+
require.NotEmpty(t, names)
15+
16+
// Ensure sorted ascending
17+
sorted := append([]string(nil), names...)
18+
sort.Strings(sorted)
19+
require.Equal(t, sorted, names)
20+
21+
// Ensure each entry parses and expands to site-only
22+
for _, name := range names {
23+
// Skip `all` and `application_connect` since they do not
24+
// expand into a low level scope.
25+
// They are handled differently.
26+
if name == string(ScopeAll) || name == string(ScopeApplicationConnect) {
27+
continue
28+
}
29+
30+
res, act, ok := parseLowLevelScope(ScopeName(name))
31+
require.Truef(t, ok, "catalog entry should parse: %s", name)
32+
33+
s, err := ScopeName(name).Expand()
34+
require.NoErrorf(t, err, "catalog entry should expand: %s", name)
35+
require.Len(t, s.Site, 1)
36+
require.Equal(t, res, s.Site[0].ResourceType)
37+
require.Equal(t, act, s.Site[0].Action)
38+
require.Empty(t, s.Org)
39+
require.Empty(t, s.User)
40+
}
41+
}
42+
43+
func TestIsExternalScope(t *testing.T) {
44+
t.Parallel()
45+
46+
require.True(t, IsExternalScope("workspace:read"))
47+
require.True(t, IsExternalScope("template:use"))
48+
require.True(t, IsExternalScope("workspace:*"))
49+
require.False(t, IsExternalScope("debug_info:read")) // internal-only
50+
require.False(t, IsExternalScope("unknown:read"))
51+
}

scripts/check-scopes/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,5 @@ When the tool reports missing values:
4040
make -B gen/db && make lint/check-scopes
4141
```
4242

43-
3. Decide whether each new scope is public (exposed in the catalog) or internal-only (handled by the catalog task).
43+
3. Decide whether each new scope is public (exposed in the catalog) or internal-only.
44+
- If public, add it to the curated map in `coderd/rbac/scopes_catalog.go` (`externalLowLevel`) so it appears in the public catalog and can be requested by users.

scripts/check-scopes/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func main() {
5353
_, _ = fmt.Fprintf(os.Stderr, " ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS '%s';\n", m)
5454
}
5555
_, _ = fmt.Fprintln(os.Stderr)
56-
_, _ = fmt.Fprintln(os.Stderr, "Also decide if each new scope is public (exposed in the catalog) or internal-only (catalog task).")
56+
_, _ = fmt.Fprintln(os.Stderr, "Also decide if each new scope is external (exposed in the `externalLowLevel` in coderd/rbac/scopes_catalog.go) or internal-only.")
5757
os.Exit(1)
5858
}
5959

0 commit comments

Comments
 (0)