diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 4b66d08170..9a800aa03d 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -205,6 +205,11 @@ func parseLowLevelScope(name ScopeName) (resource string, action policy.Action, if !exists { return "", "", false } + + if act == policy.WildcardSymbol { + return res, policy.WildcardSymbol, true + } + if _, exists := def.Actions[policy.Action(act)]; !exists { return "", "", false } diff --git a/coderd/rbac/scopes_catalog.go b/coderd/rbac/scopes_catalog.go new file mode 100644 index 0000000000..70f8720a61 --- /dev/null +++ b/coderd/rbac/scopes_catalog.go @@ -0,0 +1,87 @@ +package rbac + +import ( + "sort" + "strings" +) + +// externalLowLevel is the curated set of low-level scope names exposed to users. +// Any valid resource:action pair not in this set is considered internal-only +// and must not be user-requestable. +var externalLowLevel = map[ScopeName]struct{}{ + // Workspaces + "workspace:read": {}, + "workspace:create": {}, + "workspace:update": {}, + "workspace:delete": {}, + "workspace:ssh": {}, + "workspace:start": {}, + "workspace:stop": {}, + "workspace:application_connect": {}, + "workspace:*": {}, + + // Templates + "template:read": {}, + "template:create": {}, + "template:update": {}, + "template:delete": {}, + "template:use": {}, + "template:*": {}, + + // API keys (self-management) + "api_key:read": {}, + "api_key:create": {}, + "api_key:update": {}, + "api_key:delete": {}, + "api_key:*": {}, + + // Files + "file:read": {}, + "file:create": {}, + "file:*": {}, + + // Users (personal profile only) + "user:read_personal": {}, + "user:update_personal": {}, + + // User secrets + "user_secret:read": {}, + "user_secret:create": {}, + "user_secret:update": {}, + "user_secret:delete": {}, + "user_secret:*": {}, +} + +// IsExternalScope returns true if the scope is public, including the +// `all` and `application_connect` special scopes and the curated +// low-level resource:action scopes. +func IsExternalScope(name ScopeName) bool { + switch name { + case ScopeAll, ScopeApplicationConnect: + return true + } + if _, ok := externalLowLevel[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. +func ExternalScopeNames() []string { + names := make([]string, 0, len(externalLowLevel)+2) + names = append(names, string(ScopeAll)) + names = append(names, string(ScopeApplicationConnect)) + + // curated low-level names, filtered for validity + for name := range externalLowLevel { + if _, _, ok := parseLowLevelScope(name); ok { + names = append(names, string(name)) + } + } + + sort.Slice(names, func(i, j int) bool { return strings.Compare(names[i], names[j]) < 0 }) + return names +} diff --git a/coderd/rbac/scopes_catalog_internal_test.go b/coderd/rbac/scopes_catalog_internal_test.go new file mode 100644 index 0000000000..5c47ebe8fa --- /dev/null +++ b/coderd/rbac/scopes_catalog_internal_test.go @@ -0,0 +1,51 @@ +package rbac + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExternalScopeNames(t *testing.T) { + t.Parallel() + + names := ExternalScopeNames() + require.NotEmpty(t, names) + + // Ensure sorted ascending + sorted := append([]string(nil), names...) + sort.Strings(sorted) + require.Equal(t, sorted, names) + + // Ensure each entry parses and expands to site-only + for _, name := range names { + // Skip `all` and `application_connect` since they do not + // expand into a low level scope. + // They are handled differently. + if name == string(ScopeAll) || name == string(ScopeApplicationConnect) { + continue + } + + res, act, ok := parseLowLevelScope(ScopeName(name)) + require.Truef(t, ok, "catalog entry should parse: %s", name) + + s, err := ScopeName(name).Expand() + require.NoErrorf(t, err, "catalog entry should expand: %s", name) + require.Len(t, s.Site, 1) + require.Equal(t, res, s.Site[0].ResourceType) + require.Equal(t, act, s.Site[0].Action) + require.Empty(t, s.Org) + require.Empty(t, s.User) + } +} + +func TestIsExternalScope(t *testing.T) { + t.Parallel() + + require.True(t, IsExternalScope("workspace:read")) + require.True(t, IsExternalScope("template:use")) + require.True(t, IsExternalScope("workspace:*")) + require.False(t, IsExternalScope("debug_info:read")) // internal-only + require.False(t, IsExternalScope("unknown:read")) +} diff --git a/scripts/check-scopes/README.md b/scripts/check-scopes/README.md index 4060ba9b07..10c384dfae 100644 --- a/scripts/check-scopes/README.md +++ b/scripts/check-scopes/README.md @@ -40,4 +40,5 @@ When the tool reports missing values: make -B gen/db && make lint/check-scopes ``` -3. Decide whether each new scope is public (exposed in the catalog) or internal-only (handled by the catalog task). +3. Decide whether each new scope is public (exposed in the catalog) or internal-only. + - 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. diff --git a/scripts/check-scopes/main.go b/scripts/check-scopes/main.go index 310687bc0d..503af5b4ad 100644 --- a/scripts/check-scopes/main.go +++ b/scripts/check-scopes/main.go @@ -53,7 +53,7 @@ func main() { _, _ = fmt.Fprintf(os.Stderr, " ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS '%s';\n", m) } _, _ = fmt.Fprintln(os.Stderr) - _, _ = fmt.Fprintln(os.Stderr, "Also decide if each new scope is public (exposed in the catalog) or internal-only (catalog task).") + _, _ = 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.") os.Exit(1) }