feat: implement API key scopes database migration (#19861)

Added database migration for API key scopes.

Fixes #19845
This commit is contained in:
Thomas Kosiewski
2025-09-22 19:26:51 +02:00
committed by GitHub
parent a30c30724b
commit fb0ce389a6
26 changed files with 1252 additions and 71 deletions
+60 -4
View File
@@ -2,6 +2,7 @@ package rbac
import (
"fmt"
"strings"
"github.com/google/uuid"
@@ -143,18 +144,73 @@ func AllowListAll() AllowListElement {
return AllowListElement{ID: policy.WildcardSymbol, Type: policy.WildcardSymbol}
}
// String encodes the allow list element into the canonical database representation
// "type:id". This avoids fragile manual concatenations scattered across the codebase.
func (e AllowListElement) String() string {
return e.Type + ":" + e.ID
}
func (s Scope) Expand() (Scope, error) {
return s, nil
}
func (s Scope) Name() RoleIdentifier {
return s.Role.Identifier
return s.Identifier
}
func ExpandScope(scope ScopeName) (Scope, error) {
role, ok := builtinScopes[scope]
if role, ok := builtinScopes[scope]; ok {
return role, nil
}
if res, act, ok := parseLowLevelScope(scope); ok {
return expandLowLevel(res, act), nil
}
return Scope{}, xerrors.Errorf("no scope named %q", scope)
}
// ParseResourceAction parses a scope string formatted as "<resource>:<action>"
// and returns the resource and action components. This is the common parsing
// logic shared between RBAC and database validation.
func ParseResourceAction(scope string) (resource string, action string, ok bool) {
parts := strings.SplitN(scope, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
return parts[0], parts[1], true
}
// parseLowLevelScope parses a low-level scope name formatted as
// "<resource>:<action>" and validates it against RBACPermissions.
// Returns the resource and action if valid.
func parseLowLevelScope(name ScopeName) (resource string, action policy.Action, ok bool) {
res, act, ok := ParseResourceAction(string(name))
if !ok {
return Scope{}, xerrors.Errorf("no scope named %q", scope)
return "", "", false
}
def, exists := policy.RBACPermissions[res]
if !exists {
return "", "", false
}
if _, exists := def.Actions[policy.Action(act)]; !exists {
return "", "", false
}
return res, policy.Action(act), true
}
// expandLowLevel constructs a site-only Scope with a single permission for the
// given resource and action. This mirrors how builtin scopes are represented
// but is restricted to site-level only.
func expandLowLevel(resource string, action policy.Action) Scope {
return Scope{
Role: Role{
Identifier: RoleIdentifier{Name: fmt.Sprintf("Scope_%s:%s", resource, action)},
DisplayName: fmt.Sprintf("%s:%s", resource, action),
Site: []Permission{{ResourceType: resource, Action: action}},
Org: map[string][]Permission{},
User: []Permission{},
},
// Low-level scopes intentionally return an empty allow list.
AllowIDList: []AllowListElement{},
}
return role, nil
}
+63
View File
@@ -0,0 +1,63 @@
package rbac_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
)
func TestExpandScope(t *testing.T) {
t.Parallel()
t.Run("low_level_pairs", func(t *testing.T) {
t.Parallel()
cases := []struct {
name string
resource string
action policy.Action
}{
{name: "workspace:start", resource: rbac.ResourceWorkspace.Type, action: policy.ActionWorkspaceStart},
{name: "workspace:ssh", resource: rbac.ResourceWorkspace.Type, action: policy.ActionSSH},
{name: "template:use", resource: rbac.ResourceTemplate.Type, action: policy.ActionUse},
{name: "api_key:read", resource: rbac.ResourceApiKey.Type, action: policy.ActionRead},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, err := rbac.ScopeName(tc.name).Expand()
require.NoError(t, err)
// site-only single permission
require.Len(t, s.Site, 1)
require.Equal(t, tc.resource, s.Site[0].ResourceType)
require.Equal(t, tc.action, s.Site[0].Action)
require.Empty(t, s.Org)
require.Empty(t, s.User)
require.Len(t, s.AllowIDList, 0)
})
}
})
t.Run("invalid_low_level", func(t *testing.T) {
t.Parallel()
invalid := []string{
"", // empty
"workspace:", // missing action
":read", // missing resource
"unknown:read", // unknown resource
"workspace:bogus", // unknown action
"a:b:c", // too many parts
}
for _, name := range invalid {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := rbac.ScopeName(name).Expand()
require.Error(t, err)
})
}
})
}