mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user