Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a672066
feat(coderd/rbac): compare scopes by permission coverage
BobbyHo Aug 14, 2026
4315706
Merge branch 'main' into plat479-1-rbac-scope-coverage
BobbyHo Aug 17, 2026
787c461
docs(coderd/rbac): shorten the ScopesCover doc comment
BobbyHo Aug 17, 2026
2d39e04
Merge branch 'main' into plat479-1-rbac-scope-coverage
BobbyHo Aug 18, 2026
2f6c44e
fix(coderd/rbac): guard allowed-side org and user permissions
BobbyHo Aug 18, 2026
bd40270
refactor(coderd/rbac): drop the unreachable negative skip in coverage
BobbyHo Aug 18, 2026
3139c54
test(coderd/rbac): pin the scope coverage table's weak assertions
BobbyHo Aug 18, 2026
9276bb8
refactor(coderd/rbac): make the coverage guards reachable from tests
BobbyHo Aug 18, 2026
865eb9a
refactor(coderd/rbac): share the alias table and name the canonical c…
BobbyHo Aug 18, 2026
26a6bed
docs(coderd/rbac): document the expansion invariant where it can be b…
BobbyHo Aug 18, 2026
7cca7b3
Merge branch 'main' into plat479-1-rbac-scope-coverage
BobbyHo Aug 19, 2026
1678a77
test(coderd/rbac): pin the coverage guards at one strength
BobbyHo Aug 19, 2026
aba3c6c
fix(coderd/rbac): name the scope once in expansion errors
BobbyHo Aug 19, 2026
9a08105
docs(coderd/rbac): correct the external scope list contract
BobbyHo Aug 19, 2026
2fcce8b
docs(coderd/rbac): trim the restated coverage invariant
BobbyHo Aug 19, 2026
a775a48
docs(coderd/rbac): correct the negative permission cross-reference
BobbyHo Aug 19, 2026
e0c0d4a
docs(coderd/rbac): name every category IsExternalScope admits
BobbyHo Aug 19, 2026
189740d
test(coderd/rbac): pin the alias list invariants on the alias table
BobbyHo Aug 19, 2026
7d08e49
Merge branch 'main' into plat479-1-rbac-scope-coverage
BobbyHo Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions coderd/rbac/scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,15 @@ func (s Scope) Name() RoleIdentifier {
return s.Identifier
}

// ExpandScope resolves a scope name to the permissions it grants, from the
// builtin scopes, the composite coder:* scopes, or a low-level resource:action
// pair. The name must be canonical: the `all` and `application_connect`
// aliases IsExternalScope accepts are not scope names here, so canonicalize
// with CanonicalScopeName first.
//
// Every expansion populates Site only, with a wildcard allow list and no
// negative permissions. ScopesCover depends on that shape and refuses a scope
// that breaks it.
func ExpandScope(scope ScopeName) (Scope, error) {
if role, ok := builtinScopes[scope]; ok {
return role, nil
Expand Down Expand Up @@ -318,3 +327,120 @@ func expandLowLevel(resource string, action policy.Action) Scope {
AllowIDList: []AllowListElement{{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}},
}
}

// ScopesCover reports whether every permission the requested scope grants is
// also granted by at least one of the allowed scopes. It compares expanded
// permissions, not names, so `coder:workspaces.access` covers `workspace:read`
// and `coder:all` covers everything.
//
// Only a wildcard grant covers a wildcard request: `workspace:*` also
// authorizes the actions added tomorrow, which no list of today's can.
//
// Both sides must already be canonical. IsExternalScope also admits the `all`
// and `application_connect` aliases, which are not expandable names, so
// canonicalize between validating a name and asking about its coverage.
//
// Coverage models site-level grants only. Anything it cannot fully compare, an
// unknown name or a scope carrying more than site permissions, is an error on
// either side rather than a false, since a caller cannot act on coverage
// decided from a fraction of the authority.
func ScopesCover(canonicalAllowed []ScopeName, canonicalRequested ScopeName) (bool, error) {
Comment thread
BobbyHo marked this conversation as resolved.
want, err := ExpandScope(canonicalRequested)
if err != nil {
return false, xerrors.Errorf("expand requested scope: %w", err)
Comment thread
BobbyHo marked this conversation as resolved.
}

grants := make([]namedScope, 0, len(canonicalAllowed))
for _, name := range canonicalAllowed {
expanded, err := ExpandScope(name)
if err != nil {
return false, xerrors.Errorf("expand allowed scope: %w", err)
}
grants = append(grants, namedScope{name: name, scope: expanded})
}

return scopesCoverExpanded(grants, namedScope{name: canonicalRequested, scope: want})
}

// namedScope pairs an expanded scope with the name the caller spelled, so a
// guard error can name the scope as it was requested rather than as it expanded.
type namedScope struct {
name ScopeName
scope Scope
}

// scopesCoverExpanded is the comparison ScopesCover runs once both sides are
// expanded. It is separate because every Scope ExpandScope builds satisfies
// the guards below, so driving synthetic Scope values through this function is
// the only way to reach them. Testing checkCoverable alone would leave
// unverified the part that matters most: that both sides are actually checked.
func scopesCoverExpanded(allowed []namedScope, requested namedScope) (bool, error) {
if err := checkCoverable(requested.scope, coverageSideRequested, requested.name); err != nil {
return false, err
}

granted := make([]Permission, 0, len(allowed)*4)
Comment thread
BobbyHo marked this conversation as resolved.
for _, entry := range allowed {
if err := checkCoverable(entry.scope, coverageSideAllowed, entry.name); err != nil {
return false, err
}
granted = append(granted, entry.scope.Site...)
}

for _, needed := range requested.scope.Site {
if !permissionCovered(needed, granted) {
return false, nil
}
}
return true, nil
}

// Which side of a coverage comparison a scope sits on. Both sides are held to
// the same invariant, so the side only distinguishes the error messages.
const (
coverageSideRequested = "requested"
coverageSideAllowed = "allowed"
)

// checkCoverable reports an error when scope carries authority that coverage
Comment thread
BobbyHo marked this conversation as resolved.
// cannot compare, rather than letting the comparison run on the part that is
// modeled. Each guard names authority coverage would not otherwise read: an org
// or user grant may itself carry a negative permission, a negative site
// permission would read as a grant on a matching resource and action (see
// permissionCovered), and an allow list makes the Site permissions
// conditional, so reading them as unconditional would overstate what the scope
// grants.
func checkCoverable(scope Scope, side string, name ScopeName) error {
if len(scope.User) > 0 || len(scope.ByOrgID) > 0 {
return xerrors.Errorf("%s scope %q grants org or user permissions, which coverage does not model", side, name)
}
for _, perm := range scope.Site {
if perm.Negate {
return xerrors.Errorf("%s scope %q carries a negative permission, which coverage does not model", side, name)
}
}
if !allowListContainsAll(scope.AllowIDList) {
return xerrors.Errorf("%s scope %q carries a resource allow list, which coverage does not model", side, name)
}
return nil
}

// permissionCovered reports whether any granted permission subsumes needed,
// treating the wildcard resource type and action as covering every value.
//
// granted must carry no negative permissions; checkCoverable refuses a scope
// holding one before ScopesCover gets here. Skipping a negative leaves any
// wildcard beside it free to match, so an "everything except delete" scope
// would read as covering delete.
func permissionCovered(needed Permission, granted []Permission) bool {
Comment thread
BobbyHo marked this conversation as resolved.
for _, perm := range granted {
if perm.ResourceType != needed.ResourceType && perm.ResourceType != policy.WildcardSymbol {
continue
}
if perm.Action != needed.Action && perm.Action != policy.WildcardSymbol {
continue
}
return true
}
return false
}
51 changes: 43 additions & 8 deletions coderd/rbac/scopes_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,28 @@ var externalComposite = map[ScopeName]struct{}{
"coder:apikeys.manage_self": {},
}

// IsExternalScope returns true if the scope is public, including the
// `all` and `application_connect` special scopes and the curated
// low-level resource:action scopes.
// scopeAliases maps the spellings accepted for backward compatibility onto the
// names the api_key_scope enum stores. IsExternalScope accepts every key and
// CanonicalScopeName rewrites it to its value, so the two agree by reading one
// table rather than by keeping two switches in step. Drift between them is
// worse in one direction than the other: a name accepted as public but not
// rewritten is declared requestable and then fails to expand on every request
// naming it.
var scopeAliases = map[ScopeName]ScopeName{
"all": ScopeAll,
"application_connect": ScopeApplicationConnect,
}

// IsExternalScope returns true if the scope is public: the `all` and
// `application_connect` aliases, the canonical `coder:all` and
// `coder:application_connect`, a curated low-level resource:action scope, or a
// curated composite `coder:*` scope.
func IsExternalScope(name ScopeName) bool {
if _, ok := scopeAliases[name]; ok {
return true
}
switch name {
// Include `all` and `application_connect` for backward compatibility.
case "all", ScopeAll, "application_connect", ScopeApplicationConnect:
case ScopeAll, ScopeApplicationConnect:
return true
}
if _, ok := externalLowLevel[name]; ok {
Expand All @@ -104,9 +119,29 @@ func IsExternalScope(name ScopeName) bool {
return false
}

// ExternalScopeNames returns a sorted list of all public scopes, which
// includes the `all` and `application_connect` special scopes, curated
// low-level resource:action names, and curated composite coder:* scopes.
// CanonicalScopeName maps the backward-compatibility aliases IsExternalScope
// accepts onto the names the api_key_scope enum stores. Any other name is
// returned unchanged.
//
// IsExternalScope answers whether a name may be requested; it does not answer
// how that name is spelled once persisted. The aliases `all` and
// `application_connect` are accepted but are not enum members, so a caller
// that stores what it validated must canonicalize in between.
func CanonicalScopeName(name ScopeName) ScopeName {
Comment thread
BobbyHo marked this conversation as resolved.
Comment thread
BobbyHo marked this conversation as resolved.
if canonical, ok := scopeAliases[name]; ok {
return canonical
}
return name
}

// ExternalScopeNames returns a sorted list of all public scopes: the canonical
// `coder:all` and `coder:application_connect` spellings, the curated low-level
// resource:action names, and the curated composite coder:* scopes.
//
// Every name returned is canonical, so the list omits the bare `all` and
// `application_connect` aliases IsExternalScope also accepts. A caller matching
// a client-supplied name against this list must run it through
// CanonicalScopeName first, or reject a spelling the same package calls public.
func ExternalScopeNames() []string {
names := make([]string, 0, len(externalLowLevel)+len(externalComposite)+2)
names = append(names, string(ScopeAll))
Expand Down
167 changes: 167 additions & 0 deletions coderd/rbac/scopes_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package rbac

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/coderd/rbac/policy"
)

var (
workspaceRead = Permission{ResourceType: "workspace", Action: policy.ActionRead}
workspaceWildcard = Permission{ResourceType: "workspace", Action: policy.WildcardSymbol}
workspaceDeleteNegate = Permission{ResourceType: "workspace", Action: policy.ActionDelete, Negate: true}
)

// coverableScope is the shape every ExpandScope result has: site permissions
// only, wildcard allow list, no negatives.
func coverableScope(perms ...Permission) Scope {
return Scope{
Role: Role{Site: perms},
AllowIDList: []AllowListElement{AllowListAll()},
}
}

// TestScopeAliases asserts what the shared table exists to guarantee, which no
// test outside this package can: every alias is public, resolves to a public
// name, and resolves to one the RBAC layer can expand. A name IsExternalScope
// calls public but ExpandScope rejects is requestable in name only, and every
// request naming it fails. Iterating the table rather than naming the two
// aliases means a third is covered the day it is added.
func TestScopeAliases(t *testing.T) {
t.Parallel()

require.NotEmpty(t, scopeAliases)

for alias, canonical := range scopeAliases {
require.Truef(t, IsExternalScope(alias), "alias %q must be public", alias)
require.Equalf(t, canonical, CanonicalScopeName(alias), "alias %q", alias)

// An alias is a second spelling of a scope, not a scope of its own.
require.Truef(t, IsExternalScope(canonical), "canonical %q must be public", canonical)
_, err := ExpandScope(canonical)
require.NoErrorf(t, err, "canonical %q must expand", canonical)

// The alias itself does not expand, which is what makes
// canonicalization mandatory before storage or coverage rather than a
// tidying step callers may skip.
_, err = ExpandScope(alias)
require.Errorf(t, err, "alias %q must not expand directly", alias)

// The list a client reads offers the canonical spelling and only that
// one, so a caller can request a name from it and store what it
// requested. Listing the alias too would offer two names for one scope,
// one of which fails to expand once stored.
require.NotContainsf(t, ExternalScopeNames(), string(alias), "list must omit alias %q", alias)
require.Containsf(t, ExternalScopeNames(), string(canonical), "list must offer %q", canonical)
}
}
Comment thread
BobbyHo marked this conversation as resolved.

// TestScopesCoverGuards drives Scope values that no catalog entry produces.
// The guards exist for authority ScopeName inputs cannot express today, so
// ScopesCover cannot reach them and they would otherwise ship unverified.
//
// Each shape runs on both sides of the comparison, with the opposite side
// coverable, so a guard consulted on only one side fails here.
func TestScopesCoverGuards(t *testing.T) {
t.Parallel()

tests := []struct {
name string
scope Scope
wantErr string
}{
{
name: "SitePermissionsOnly",
scope: coverableScope(workspaceRead),
},
{
name: "UserPermission",
scope: Scope{
Role: Role{
Site: []Permission{workspaceRead},
User: []Permission{workspaceRead},
},
AllowIDList: []AllowListElement{AllowListAll()},
},
wantErr: "grants org or user permissions",
},
{
// The case the guards were added for: a scope granting every
// workspace action except delete. The permission coverage reads is
// harmless, and the one it does not read carves delete back out, so
// comparing on Site alone would answer a request for
// workspace:delete from a wildcard the scope has already qualified.
name: "NegativeUserPermission",
scope: Scope{
Role: Role{
Site: []Permission{workspaceWildcard},
User: []Permission{workspaceDeleteNegate},
},
AllowIDList: []AllowListElement{AllowListAll()},
},
wantErr: "grants org or user permissions",
},
{
name: "OrgPermission",
scope: Scope{
Role: Role{
Site: []Permission{workspaceRead},
ByOrgID: map[string]OrgPermissions{"00000000-0000-0000-0000-000000000001": {}},
},
AllowIDList: []AllowListElement{AllowListAll()},
},
wantErr: "grants org or user permissions",
},
{
name: "NegativeSitePermission",
scope: coverableScope(workspaceWildcard, workspaceDeleteNegate),
wantErr: "carries a negative permission",
},
{
name: "NarrowedAllowList",
scope: Scope{
Role: Role{Site: []Permission{workspaceRead}},
AllowIDList: []AllowListElement{{Type: "workspace", ID: "00000000-0000-0000-0000-000000000002"}},
},
wantErr: "carries a resource allow list",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

// The opposite side always covers, so a guard error stays
// distinguishable from an ordinary uncovered result.
cleanGrant := namedScope{name: "clean_scope", scope: coverableScope(workspaceWildcard)}
cleanRequest := namedScope{name: "clean_scope", scope: coverableScope(workspaceRead)}
under := namedScope{name: "test_scope", scope: test.scope}

sides := []struct {
side string
allowed []namedScope
requested namedScope
}{
{side: coverageSideRequested, allowed: []namedScope{cleanGrant}, requested: under},
{side: coverageSideAllowed, allowed: []namedScope{under}, requested: cleanRequest},
}

for _, args := range sides {
side := args.side
got, err := scopesCoverExpanded(args.allowed, args.requested)
if test.wantErr == "" {
require.NoErrorf(t, err, "side %q", side)
require.Truef(t, got, "side %q", side)
continue
}
require.ErrorContainsf(t, err, test.wantErr, "side %q", side)
// The side names itself, so an operator reading the error can
// tell which half of the comparison was undecidable.
require.ErrorContainsf(t, err, side+` scope "test_scope"`, "side %q", side)
require.Falsef(t, got, "an undecided comparison must not report coverage, side %q", side)
}
})
}
}
Loading
Loading