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.
This commit is contained in:
Thomas Kosiewski
2025-09-26 11:30:28 +02:00
committed by GitHub
parent eb55f0ab19
commit 47c92ad1d2
5 changed files with 146 additions and 2 deletions
+5
View File
@@ -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
}
+87
View File
@@ -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
}
@@ -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"))
}