From a6720664d7125a70e5e1407f513c5451b161bfb5 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 14 Aug 2026 17:02:06 +0000 Subject: [PATCH 01/33] feat(coderd/rbac): compare scopes by permission coverage Add ScopesCover, which reports whether every permission a requested scope grants is also granted by at least one of a set of allowed scopes. It expands both sides and compares the resulting permissions, so coder:workspaces.access covers workspace:read even though it never names it, and coder:all covers everything. The comparison is deliberately asymmetric. Positive permissions on the allowed side that it does not model are dropped, which can only make the answer stricter. Anything unmodelled on the requested side is an error instead, because ignoring it would answer "covered" about authority that was never compared. Negative permissions are the exception and fail closed on both sides, since dropping an anti-grant from the ceiling would widen it rather than narrow it. Add CanonicalScopeName, which maps the backward-compatibility aliases IsExternalScope accepts onto the names the api_key_scope enum stores. IsExternalScope answers whether a name may be requested, not how that name is spelled once persisted, so a caller that stores what it validated has to canonicalize in between. Both functions are added without production callers. The OAuth2 authorize endpoint uses them to negotiate a requested scope against an app's configured allowlist, which follows in a separate change. --- coderd/rbac/scopes.go | 91 +++++++++++++++++++++ coderd/rbac/scopes_catalog.go | 18 +++++ coderd/rbac/scopes_test.go | 148 ++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 7cbec46d741..a69628ae178 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -318,3 +318,94 @@ 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 is the semantic form +// of "is this request within this ceiling", as opposed to comparing the names +// themselves: `coder:workspaces.access` covers `workspace:read` because it +// expands to include it, and `coder:all` covers everything. +// +// Names must be canonical (see CanonicalScopeName). An unknown name on either +// side is an error rather than a false, since a caller cannot tell those apart +// safely. +// +// The comparison is deliberately asymmetric about what it ignores. Positive +// permissions on the allowed side that this does not model are dropped, which +// can only make the answer stricter. Anything on the requested side that is +// not modeled fails closed instead, because ignoring it would answer +// "covered" about authority that was never compared. +// +// Negative permissions are the exception to that asymmetry and fail closed on +// both sides. Dropping an anti-grant from the ceiling would widen it, so the +// direction that makes the rest of the allowed side safe to ignore does not +// hold for them. +func ScopesCover(allowed []ScopeName, requested ScopeName) (bool, error) { + want, err := ExpandScope(requested) + if err != nil { + return false, xerrors.Errorf("expand requested scope: %w", err) + } + // Scope expansion populates Site only, with a wildcard allow list and no + // negative permissions. These guards hold that invariant: if a future + // scope breaks it, coverage stops being decidable here and the request is + // refused rather than approved on an incomplete comparison. + if len(want.User) > 0 || len(want.ByOrgID) > 0 { + return false, xerrors.Errorf("scope %q grants org or user permissions, which coverage does not model", requested) + } + for _, perm := range want.Site { + if perm.Negate { + return false, xerrors.Errorf("scope %q carries a negative permission, which coverage does not model", requested) + } + } + if !allowListContainsAll(want.AllowIDList) { + return false, xerrors.Errorf("scope %q carries a resource allow list, which coverage does not model", requested) + } + + granted := make([]Permission, 0, len(allowed)*4) + for _, name := range allowed { + expanded, err := ExpandScope(name) + if err != nil { + return false, xerrors.Errorf("expand allowed scope %q: %w", name, err) + } + // A narrower allow list on the allowed side would make these + // permissions conditional, and treating them as unconditional would + // overstate the ceiling. + if !allowListContainsAll(expanded.AllowIDList) { + return false, xerrors.Errorf("allowed scope %q carries a resource allow list, which coverage does not model", name) + } + // A negative permission is the one thing on this side that cannot be + // dropped safely. Ignoring an unmodelled grant narrows the ceiling, + // but ignoring an anti-grant widens it: an "everything except delete" + // scope would otherwise cover a request for delete. + for _, perm := range expanded.Site { + if perm.Negate { + return false, xerrors.Errorf("allowed scope %q carries a negative permission, which coverage does not model", name) + } + } + granted = append(granted, expanded.Site...) + } + + for _, needed := range want.Site { + if !permissionCovered(needed, granted) { + return false, nil + } + } + return true, nil +} + +// permissionCovered reports whether any granted permission subsumes needed, +// treating the wildcard resource type and action as covering every value. +func permissionCovered(needed Permission, granted []Permission) bool { + for _, perm := range granted { + if perm.Negate { + continue + } + if perm.ResourceType != needed.ResourceType && perm.ResourceType != policy.WildcardSymbol { + continue + } + if perm.Action != needed.Action && perm.Action != policy.WildcardSymbol { + continue + } + return true + } + return false +} diff --git a/coderd/rbac/scopes_catalog.go b/coderd/rbac/scopes_catalog.go index 04304681a69..be129b204fe 100644 --- a/coderd/rbac/scopes_catalog.go +++ b/coderd/rbac/scopes_catalog.go @@ -104,6 +104,24 @@ func IsExternalScope(name ScopeName) bool { return false } +// 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 { + switch name { + case "all": + return ScopeAll + case "application_connect": + return ScopeApplicationConnect + } + return name +} + // 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. diff --git a/coderd/rbac/scopes_test.go b/coderd/rbac/scopes_test.go index 270f6ff0285..27a0171287f 100644 --- a/coderd/rbac/scopes_test.go +++ b/coderd/rbac/scopes_test.go @@ -61,3 +61,151 @@ func TestExpandScope(t *testing.T) { } }) } + +func TestScopesCover(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + allowed []rbac.ScopeName + requested rbac.ScopeName + want bool + wantErr bool + }{ + { + name: "IdenticalName", + allowed: []rbac.ScopeName{"workspace:read"}, + requested: "workspace:read", + want: true, + }, + { + // The case name matching cannot answer: the composite expands to + // include the requested permission, so the request is within the + // authority the composite already grants. + name: "CompositeCoversItsMember", + allowed: []rbac.ScopeName{"coder:workspaces.access"}, + requested: "workspace:ssh", + want: true, + }, + { + name: "CompositeDoesNotCoverNonMember", + allowed: []rbac.ScopeName{"coder:workspaces.access"}, + requested: "workspace:delete", + want: false, + }, + { + // Same resource, different action. Coverage compares the pair, + // not the resource alone. + name: "CompositeDoesNotCoverWiderActionOnCoveredResource", + allowed: []rbac.ScopeName{"coder:workspaces.access"}, + requested: "template:update", + want: false, + }, + { + name: "AllCoversEverything", + allowed: []rbac.ScopeName{rbac.ScopeAll}, + requested: "user_secret:delete", + want: true, + }, + { + name: "NarrowScopeDoesNotCoverAll", + allowed: []rbac.ScopeName{"workspace:read"}, + requested: rbac.ScopeAll, + want: false, + }, + { + name: "ResourceWildcardCoversOneAction", + allowed: []rbac.ScopeName{"workspace:*"}, + requested: "workspace:ssh", + want: true, + }, + { + name: "OneActionDoesNotCoverResourceWildcard", + allowed: []rbac.ScopeName{"workspace:ssh"}, + requested: "workspace:*", + want: false, + }, + { + // A composite is covered only when every permission it expands + // to is granted, so a strict subset of them is not enough. + name: "PartialUnionDoesNotCoverComposite", + allowed: []rbac.ScopeName{"template:read", "file:create"}, + requested: "coder:templates.build", + want: false, + }, + { + // The allowed side is a union rather than a set of independent + // candidates, so one composite's permissions may be drawn from + // several allowed entries at once. + name: "UnionOfAllowedScopesCoversComposite", + allowed: []rbac.ScopeName{"template:read", "file:*", "provisioner_jobs:read"}, + requested: "coder:templates.build", + want: true, + }, + { + name: "EmptyAllowedCoversNothing", + allowed: nil, + requested: "workspace:read", + want: false, + }, + { + // Not a false: a caller cannot distinguish "known and not + // covered" from "we could not tell", so an undecidable + // comparison is surfaced rather than answered. + name: "UnknownRequestedScopeErrors", + allowed: []rbac.ScopeName{rbac.ScopeAll}, + requested: "not_a_real_scope", + wantErr: true, + }, + { + name: "UnknownAllowedScopeErrors", + allowed: []rbac.ScopeName{"not_a_real_scope"}, + requested: "workspace:read", + wantErr: true, + }, + { + // The aliases IsExternalScope accepts are not expandable names, + // so callers must canonicalize before asking about coverage. + name: "NonCanonicalAliasErrors", + allowed: []rbac.ScopeName{rbac.ScopeAll}, + requested: "all", + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := rbac.ScopesCover(test.allowed, test.requested) + if test.wantErr { + require.Error(t, err) + require.False(t, got, "an undecided comparison must not report coverage") + return + } + require.NoError(t, err) + require.Equal(t, test.want, got) + }) + } +} + +// TestScopesCoverEveryExternalScope asserts the property the OAuth2 allowlist +// check depends on: coder:all is a ceiling over the whole external catalog, and +// every catalog name covers itself. A name that cannot be compared at all would +// otherwise reject every request naming it, which is a rejection no app owner +// could act on. +func TestScopesCoverEveryExternalScope(t *testing.T) { + t.Parallel() + + for _, name := range rbac.ExternalScopeNames() { + canonical := rbac.CanonicalScopeName(rbac.ScopeName(name)) + + covered, err := rbac.ScopesCover([]rbac.ScopeName{rbac.ScopeAll}, canonical) + require.NoErrorf(t, err, "coder:all vs %q", canonical) + require.Truef(t, covered, "coder:all must cover %q", canonical) + + covered, err = rbac.ScopesCover([]rbac.ScopeName{canonical}, canonical) + require.NoErrorf(t, err, "%q vs itself", canonical) + require.Truef(t, covered, "%q must cover itself", canonical) + } +} From 787c4614f0358259851ef89713f516915fa586bf Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 17 Aug 2026 20:02:06 +0000 Subject: [PATCH 02/33] docs(coderd/rbac): shorten the ScopesCover doc comment State the rule the guards enforce, site-level grants only, instead of describing the asymmetry abstractly. The allow-list case is now covered alongside negative permissions, which the previous wording omitted even though the code treats them identically. Co-Authored-By: Claude Opus 5 --- coderd/rbac/scopes.go | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index a69628ae178..83931257590 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -320,25 +320,20 @@ func expandLowLevel(resource string, action policy.Action) Scope { } // ScopesCover reports whether every permission the requested scope grants is -// also granted by at least one of the allowed scopes. It is the semantic form -// of "is this request within this ceiling", as opposed to comparing the names -// themselves: `coder:workspaces.access` covers `workspace:read` because it -// expands to include it, and `coder:all` covers everything. +// 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. // -// Names must be canonical (see CanonicalScopeName). An unknown name on either -// side is an error rather than a false, since a caller cannot tell those apart -// safely. +// Names must be canonical (see CanonicalScopeName). An unknown name is an +// error rather than a false, since a caller cannot tell those two apart. // -// The comparison is deliberately asymmetric about what it ignores. Positive -// permissions on the allowed side that this does not model are dropped, which -// can only make the answer stricter. Anything on the requested side that is -// not modeled fails closed instead, because ignoring it would answer -// "covered" about authority that was never compared. -// -// Negative permissions are the exception to that asymmetry and fail closed on -// both sides. Dropping an anti-grant from the ceiling would widen it, so the -// direction that makes the rest of the allowed side safe to ignore does not -// hold for them. +// Coverage models site-level grants only. Anything else returns an error +// instead of being skipped, because skipping it could report "covered" about +// authority that was never compared. The one exception is an unmodelled grant +// on the allowed side, which is dropped. Dropping it only shrinks the ceiling, +// so at worst it rejects a request that would have been allowed. A negative +// permission or an allow list is never dropped, on either side, since +// dropping one would widen the ceiling rather than shrink it. func ScopesCover(allowed []ScopeName, requested ScopeName) (bool, error) { want, err := ExpandScope(requested) if err != nil { From 2f6c44e3c80e388be7986ef0d13f4f4ea414f42f Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 18 Aug 2026 22:32:42 +0000 Subject: [PATCH 03/33] fix(coderd/rbac): guard allowed-side org and user permissions ScopesCover checked the requested scope for org and user grants but not the allowed scopes, whose User and ByOrgID permissions were discarded unread. A scope granting workspace:* at site level while negating workspace:delete for the user would have covered a request for workspace:delete, because the negative that carves the action back out lives in the half coverage never examined. No catalog scope populates those fields today, so nothing was miscompared in practice. The gap mattered because these guards exist to keep the comparison fail-closed, and this one failed open. Both sides now run the same checkCoverable helper, which refuses a scope carrying org or user grants, a negative permission, or a resource allow list. The helper names the side, so an error reports which half of the comparison was undecidable. The doc comment claimed an unmodeled grant on the allowed side is dropped; nothing is dropped now, so it is gone. ScopesCover builds every Scope it reads from ExpandScope, which cannot produce these shapes, so the guards are unreachable through the public API. scopes_internal_test.go drives synthetic Scope values through checkCoverable instead. Co-Authored-By: Claude Opus 5 --- coderd/rbac/scopes.go | 74 ++++++++++---------- coderd/rbac/scopes_internal_test.go | 105 ++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 35 deletions(-) create mode 100644 coderd/rbac/scopes_internal_test.go diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 83931257590..a8ae8cb49aa 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -327,32 +327,17 @@ func expandLowLevel(resource string, action policy.Action) Scope { // Names must be canonical (see CanonicalScopeName). An unknown name is an // error rather than a false, since a caller cannot tell those two apart. // -// Coverage models site-level grants only. Anything else returns an error -// instead of being skipped, because skipping it could report "covered" about -// authority that was never compared. The one exception is an unmodelled grant -// on the allowed side, which is dropped. Dropping it only shrinks the ceiling, -// so at worst it rejects a request that would have been allowed. A negative -// permission or an allow list is never dropped, on either side, since -// dropping one would widen the ceiling rather than shrink it. +// Coverage models site-level grants only. A scope carrying anything else is +// refused on either side rather than compared on the part that is modeled, +// because comparing a subset could report "covered" about authority that was +// never examined. func ScopesCover(allowed []ScopeName, requested ScopeName) (bool, error) { want, err := ExpandScope(requested) if err != nil { return false, xerrors.Errorf("expand requested scope: %w", err) } - // Scope expansion populates Site only, with a wildcard allow list and no - // negative permissions. These guards hold that invariant: if a future - // scope breaks it, coverage stops being decidable here and the request is - // refused rather than approved on an incomplete comparison. - if len(want.User) > 0 || len(want.ByOrgID) > 0 { - return false, xerrors.Errorf("scope %q grants org or user permissions, which coverage does not model", requested) - } - for _, perm := range want.Site { - if perm.Negate { - return false, xerrors.Errorf("scope %q carries a negative permission, which coverage does not model", requested) - } - } - if !allowListContainsAll(want.AllowIDList) { - return false, xerrors.Errorf("scope %q carries a resource allow list, which coverage does not model", requested) + if err := checkCoverable(want, coverageSideRequested, requested); err != nil { + return false, err } granted := make([]Permission, 0, len(allowed)*4) @@ -361,20 +346,8 @@ func ScopesCover(allowed []ScopeName, requested ScopeName) (bool, error) { if err != nil { return false, xerrors.Errorf("expand allowed scope %q: %w", name, err) } - // A narrower allow list on the allowed side would make these - // permissions conditional, and treating them as unconditional would - // overstate the ceiling. - if !allowListContainsAll(expanded.AllowIDList) { - return false, xerrors.Errorf("allowed scope %q carries a resource allow list, which coverage does not model", name) - } - // A negative permission is the one thing on this side that cannot be - // dropped safely. Ignoring an unmodelled grant narrows the ceiling, - // but ignoring an anti-grant widens it: an "everything except delete" - // scope would otherwise cover a request for delete. - for _, perm := range expanded.Site { - if perm.Negate { - return false, xerrors.Errorf("allowed scope %q carries a negative permission, which coverage does not model", name) - } + if err := checkCoverable(expanded, coverageSideAllowed, name); err != nil { + return false, err } granted = append(granted, expanded.Site...) } @@ -387,6 +360,37 @@ func ScopesCover(allowed []ScopeName, requested ScopeName) (bool, error) { 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 +// cannot compare. Scope expansion populates Site only, with a wildcard allow +// list and no negative permissions, and coverage reads nothing else. A scope +// that breaks the invariant is refused rather than compared on its Site +// permissions alone, since the permissions left unread could be the ones that +// decide the answer: an org or user grant may itself carry a negative +// permission, and an "everything except delete" scope must not end up covering +// a request for delete. An allow list makes the Site permissions conditional, +// and reading them as unconditional would overstate the authority granted. +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. func permissionCovered(needed Permission, granted []Permission) bool { diff --git a/coderd/rbac/scopes_internal_test.go b/coderd/rbac/scopes_internal_test.go new file mode 100644 index 00000000000..c55e8276e00 --- /dev/null +++ b/coderd/rbac/scopes_internal_test.go @@ -0,0 +1,105 @@ +package rbac + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/rbac/policy" +) + +// TestCheckCoverable drives Scope values that no catalog entry produces. The +// guards exist for authority ScopeName inputs cannot express today, so the +// public function cannot reach them and they would otherwise ship unverified. +func TestCheckCoverable(t *testing.T) { + t.Parallel() + + siteRead := Permission{ResourceType: "workspace", Action: policy.ActionRead} + + tests := []struct { + name string + scope Scope + wantErr string + }{ + { + name: "SitePermissionsOnly", + scope: Scope{ + Role: Role{Site: []Permission{siteRead}}, + AllowIDList: []AllowListElement{AllowListAll()}, + }, + }, + { + name: "UserPermission", + scope: Scope{ + Role: Role{ + Site: []Permission{siteRead}, + User: []Permission{siteRead}, + }, + AllowIDList: []AllowListElement{AllowListAll()}, + }, + wantErr: "grants org or user permissions", + }, + { + // The permission coverage reads is harmless. The one it does not + // read carves an action back out, so comparing on Site alone would + // report authority the scope withholds. + name: "NegativeUserPermission", + scope: Scope{ + Role: Role{ + Site: []Permission{{ResourceType: "workspace", Action: policy.WildcardSymbol}}, + User: []Permission{{ResourceType: "workspace", Action: policy.ActionDelete, Negate: true}}, + }, + AllowIDList: []AllowListElement{AllowListAll()}, + }, + wantErr: "grants org or user permissions", + }, + { + name: "OrgPermission", + scope: Scope{ + Role: Role{ + Site: []Permission{siteRead}, + ByOrgID: map[string]OrgPermissions{"00000000-0000-0000-0000-000000000001": {}}, + }, + AllowIDList: []AllowListElement{AllowListAll()}, + }, + wantErr: "grants org or user permissions", + }, + { + name: "NegativeSitePermission", + scope: Scope{ + Role: Role{Site: []Permission{ + {ResourceType: "workspace", Action: policy.WildcardSymbol}, + {ResourceType: "workspace", Action: policy.ActionDelete, Negate: true}, + }}, + AllowIDList: []AllowListElement{AllowListAll()}, + }, + wantErr: "carries a negative permission", + }, + { + name: "NarrowedAllowList", + scope: Scope{ + Role: Role{Site: []Permission{siteRead}}, + 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() + + for _, side := range []string{coverageSideRequested, coverageSideAllowed} { + err := checkCoverable(test.scope, side, "test_scope") + if test.wantErr == "" { + require.NoErrorf(t, err, "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) + } + }) + } +} From bd4027025da8a84ddb5aef9aa9b7e1ae5ed9348f Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 18 Aug 2026 22:41:58 +0000 Subject: [PATCH 04/33] refactor(coderd/rbac): drop the unreachable negative skip in coverage permissionCovered skipped negative permissions, but checkCoverable now refuses a scope carrying one on either side, so the branch was dead. It was never defense in depth. Had a negative reached it, skipping the anti-grant would leave any wildcard beside it free to match, and a scope granting workspace:* while negating workspace:delete would report workspace:delete as covered. The skip widened the ceiling while looking like it narrowed it. The precondition moves to the doc comment, which names checkCoverable as what enforces it and says why subsumption cannot answer the question an anti-grant poses. No behavior change: the branch was unreachable. permissionCovered goes from 88.9% to 100% statement coverage. --- coderd/rbac/scopes.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index a8ae8cb49aa..bb27d400a30 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -393,11 +393,14 @@ func checkCoverable(scope Scope, side string, name ScopeName) error { // 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, because subsumption is the wrong +// question to ask about an anti-grant. Skipping a negative leaves any wildcard +// beside it free to match, so an "everything except delete" scope would read +// as covering delete, and honoring one as a grant would be worse still. func permissionCovered(needed Permission, granted []Permission) bool { for _, perm := range granted { - if perm.Negate { - continue - } if perm.ResourceType != needed.ResourceType && perm.ResourceType != policy.WildcardSymbol { continue } From 3139c54fe0dfda18835de1c73ca9530d5f82ff71 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 18 Aug 2026 22:56:54 +0000 Subject: [PATCH 05/33] test(coderd/rbac): pin the scope coverage table's weak assertions Five review findings on the coverage tests, all in scopes_test.go. CanonicalScopeName had both alias arms at zero coverage. Its only caller in the tests loops over ExternalScopeNames, which yields canonical names only, so the canonicalizing call returned its input unchanged on every iteration and read as coverage without being any. Swapping the arms, so that `all` persisted application_connect and the reverse, kept the suite green. TestCanonicalScopeName now pins the mapping and the loop appends the aliases, taking the function from 50% to 100%. The appended aliases raise branch coverage and assert a requestable name is comparable once canonicalized, but they cannot detect a swapped mapping, since both aliases resolve to scopes that cover themselves. The comment says so rather than implying the loop guards more than it does. CompositeDoesNotCoverNonMember and CompositeDoesNotCoverWiderActionOnCoveredResource both asked for an ungranted action on a resource coder:workspaces.access does grant, so they tested one branch twice and left "resource not granted at all" untested. They are now split along that line, with names that describe which failure each one is. The three wantErr rows shared a bare require.Error, so any error passed any row and a bug failing every input on the requested side would have left the allowed-side row green. wantErrContains replaces the bool and names the side. Rewording the allowed-side message as the requested-side one now fails three rows that previously all passed. Alias rejection was tested for one alias on one side. Both aliases are now tested on both sides. The allowed-side rows are the ones that earn their place: they are what would catch someone canonicalizing inside the allowed loop and widening the contract without a caller asking. --- coderd/rbac/scopes_test.go | 101 ++++++++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 25 deletions(-) diff --git a/coderd/rbac/scopes_test.go b/coderd/rbac/scopes_test.go index 27a0171287f..0edf74d5fdd 100644 --- a/coderd/rbac/scopes_test.go +++ b/coderd/rbac/scopes_test.go @@ -70,7 +70,10 @@ func TestScopesCover(t *testing.T) { allowed []rbac.ScopeName requested rbac.ScopeName want bool - wantErr bool + // wantErrContains names the side that could not be expanded. Asserting + // the side keeps rows that fail through different paths from passing + // on each other's errors. + wantErrContains string }{ { name: "IdenticalName", @@ -79,26 +82,26 @@ func TestScopesCover(t *testing.T) { want: true, }, { - // The case name matching cannot answer: the composite expands to - // include the requested permission, so the request is within the - // authority the composite already grants. + // Name matching alone cannot decide this: coder:workspaces.access + // never mentions workspace:ssh by name, but its expansion covers it. name: "CompositeCoversItsMember", allowed: []rbac.ScopeName{"coder:workspaces.access"}, requested: "workspace:ssh", want: true, }, { - name: "CompositeDoesNotCoverNonMember", + // The composite grants no permission on this resource at all. + name: "CompositeDoesNotCoverUnrelatedResource", allowed: []rbac.ScopeName{"coder:workspaces.access"}, - requested: "workspace:delete", + requested: "user_secret:delete", want: false, }, { - // Same resource, different action. Coverage compares the pair, - // not the resource alone. - name: "CompositeDoesNotCoverWiderActionOnCoveredResource", + // Same resource, action the composite does not grant. Coverage + // compares the pair, not the resource alone. + name: "CompositeDoesNotCoverUngrantedActionOnSameResource", allowed: []rbac.ScopeName{"coder:workspaces.access"}, - requested: "template:update", + requested: "workspace:delete", want: false, }, { @@ -152,24 +155,45 @@ func TestScopesCover(t *testing.T) { // Not a false: a caller cannot distinguish "known and not // covered" from "we could not tell", so an undecidable // comparison is surfaced rather than answered. - name: "UnknownRequestedScopeErrors", - allowed: []rbac.ScopeName{rbac.ScopeAll}, - requested: "not_a_real_scope", - wantErr: true, + name: "UnknownRequestedScopeErrors", + allowed: []rbac.ScopeName{rbac.ScopeAll}, + requested: "not_a_real_scope", + wantErrContains: "expand requested scope", }, { - name: "UnknownAllowedScopeErrors", - allowed: []rbac.ScopeName{"not_a_real_scope"}, - requested: "workspace:read", - wantErr: true, + name: "UnknownAllowedScopeErrors", + allowed: []rbac.ScopeName{"not_a_real_scope"}, + requested: "workspace:read", + wantErrContains: "expand allowed scope", }, { // The aliases IsExternalScope accepts are not expandable names, // so callers must canonicalize before asking about coverage. - name: "NonCanonicalAliasErrors", - allowed: []rbac.ScopeName{rbac.ScopeAll}, - requested: "all", - wantErr: true, + name: "NonCanonicalAliasErrorsRequestedAll", + allowed: []rbac.ScopeName{rbac.ScopeAll}, + requested: "all", + wantErrContains: "expand requested scope", + }, + { + name: "NonCanonicalAliasErrorsRequestedApplicationConnect", + allowed: []rbac.ScopeName{rbac.ScopeAll}, + requested: "application_connect", + wantErrContains: "expand requested scope", + }, + { + // The only row that would catch someone canonicalizing inside the + // allowed loop, which would silently widen what an allow list + // accepts without any caller asking for it. + name: "NonCanonicalAliasErrorsAllowedApplicationConnect", + allowed: []rbac.ScopeName{"application_connect"}, + requested: rbac.ScopeApplicationConnect, + wantErrContains: "expand allowed scope", + }, + { + name: "NonCanonicalAliasErrorsAllowedAll", + allowed: []rbac.ScopeName{"all"}, + requested: rbac.ScopeAll, + wantErrContains: "expand allowed scope", }, } @@ -178,8 +202,8 @@ func TestScopesCover(t *testing.T) { t.Parallel() got, err := rbac.ScopesCover(test.allowed, test.requested) - if test.wantErr { - require.Error(t, err) + if test.wantErrContains != "" { + require.ErrorContains(t, err, test.wantErrContains) require.False(t, got, "an undecided comparison must not report coverage") return } @@ -189,6 +213,23 @@ func TestScopesCover(t *testing.T) { } } +// TestCanonicalScopeName pins the alias mapping. Without it the two case arms +// could be swapped, so that requesting `all` persisted application_connect and +// the reverse, and the suite would stay green. +func TestCanonicalScopeName(t *testing.T) { + t.Parallel() + + require.Equal(t, rbac.ScopeAll, rbac.CanonicalScopeName("all")) + require.Equal(t, rbac.ScopeApplicationConnect, rbac.CanonicalScopeName("application_connect")) + + // Canonical names and low-level names are returned unchanged, so callers + // can canonicalize unconditionally. + require.Equal(t, rbac.ScopeAll, rbac.CanonicalScopeName(rbac.ScopeAll)) + require.Equal(t, rbac.ScopeApplicationConnect, rbac.CanonicalScopeName(rbac.ScopeApplicationConnect)) + require.Equal(t, rbac.ScopeName("workspace:read"), rbac.CanonicalScopeName("workspace:read")) + require.Equal(t, rbac.ScopeName("not_a_real_scope"), rbac.CanonicalScopeName("not_a_real_scope")) +} + // TestScopesCoverEveryExternalScope asserts the property the OAuth2 allowlist // check depends on: coder:all is a ceiling over the whole external catalog, and // every catalog name covers itself. A name that cannot be compared at all would @@ -197,7 +238,17 @@ func TestScopesCover(t *testing.T) { func TestScopesCoverEveryExternalScope(t *testing.T) { t.Parallel() - for _, name := range rbac.ExternalScopeNames() { + // ExternalScopeNames yields canonical names only, so canonicalizing them + // returns them unchanged and the call below would pass through on every + // iteration. Appending the aliases asserts that a name a client may + // request is comparable once canonicalized, which is the positive half of + // the contract the alias rows in TestScopesCover assert the negative of. + // It does not pin the mapping itself: both aliases resolve to scopes that + // cover themselves, so a swapped mapping still satisfies this loop. + // TestCanonicalScopeName is what catches that. + names := append(rbac.ExternalScopeNames(), "all", "application_connect") + + for _, name := range names { canonical := rbac.CanonicalScopeName(rbac.ScopeName(name)) covered, err := rbac.ScopesCover([]rbac.ScopeName{rbac.ScopeAll}, canonical) From 9276bb87f42eac8ad8aa9a103ed43c535aab7c21 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 18 Aug 2026 23:26:33 +0000 Subject: [PATCH 06/33] refactor(coderd/rbac): make the coverage guards reachable from tests ScopesCover expanded and compared in a single pass, so the invariant guards only ever ran on scopes ExpandScope had produced. Every such scope satisfies them, which left the guards unverified in the position that matters: the existing test called checkCoverable directly and could not tell whether ScopesCover consulted it on both sides, or at all. Split the comparison into scopesCoverExpanded, which takes already expanded scopes paired with the names they came from. Tests drive synthetic Scope values through it, so dropping the guard from either side now fails, as does an allowed scope that grants every workspace action except delete answering a request for delete. Expanding every allowed scope before any guard runs reorders two error paths against each other: a requested scope that fails a guard alongside an unknown allowed name now reports the expansion failure rather than the guard failure. Both return (false, error), and no ScopeName reaches that combination today. --- coderd/rbac/scopes.go | 36 ++++++++-- coderd/rbac/scopes_internal_test.go | 101 +++++++++++++++++++++------- 2 files changed, 107 insertions(+), 30 deletions(-) diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index bb27d400a30..6b0638ae0b3 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -336,23 +336,45 @@ func ScopesCover(allowed []ScopeName, requested ScopeName) (bool, error) { if err != nil { return false, xerrors.Errorf("expand requested scope: %w", err) } - if err := checkCoverable(want, coverageSideRequested, requested); err != nil { - return false, err - } - granted := make([]Permission, 0, len(allowed)*4) + grants := make([]namedScope, 0, len(allowed)) for _, name := range allowed { expanded, err := ExpandScope(name) if err != nil { return false, xerrors.Errorf("expand allowed scope %q: %w", name, err) } - if err := checkCoverable(expanded, coverageSideAllowed, name); err != nil { + grants = append(grants, namedScope{name: name, scope: expanded}) + } + + return scopesCoverExpanded(grants, namedScope{name: requested, 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) + for _, entry := range allowed { + if err := checkCoverable(entry.scope, coverageSideAllowed, entry.name); err != nil { return false, err } - granted = append(granted, expanded.Site...) + granted = append(granted, entry.scope.Site...) } - for _, needed := range want.Site { + for _, needed := range requested.scope.Site { if !permissionCovered(needed, granted) { return false, nil } diff --git a/coderd/rbac/scopes_internal_test.go b/coderd/rbac/scopes_internal_test.go index c55e8276e00..d57a4af1e01 100644 --- a/coderd/rbac/scopes_internal_test.go +++ b/coderd/rbac/scopes_internal_test.go @@ -8,13 +8,29 @@ import ( "github.com/coder/coder/v2/coderd/rbac/policy" ) -// TestCheckCoverable drives Scope values that no catalog entry produces. The -// guards exist for authority ScopeName inputs cannot express today, so the -// public function cannot reach them and they would otherwise ship unverified. -func TestCheckCoverable(t *testing.T) { - t.Parallel() +var ( + siteRead = Permission{ResourceType: "workspace", Action: policy.ActionRead} + siteWildcard = Permission{ResourceType: "workspace", Action: policy.WildcardSymbol} + siteDeleteNo = Permission{ResourceType: "workspace", Action: policy.ActionDelete, Negate: true} +) - siteRead := Permission{ResourceType: "workspace", Action: policy.ActionRead} +// 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()}, + } +} + +// 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 @@ -22,11 +38,8 @@ func TestCheckCoverable(t *testing.T) { wantErr string }{ { - name: "SitePermissionsOnly", - scope: Scope{ - Role: Role{Site: []Permission{siteRead}}, - AllowIDList: []AllowListElement{AllowListAll()}, - }, + name: "SitePermissionsOnly", + scope: coverableScope(siteRead), }, { name: "UserPermission", @@ -46,8 +59,8 @@ func TestCheckCoverable(t *testing.T) { name: "NegativeUserPermission", scope: Scope{ Role: Role{ - Site: []Permission{{ResourceType: "workspace", Action: policy.WildcardSymbol}}, - User: []Permission{{ResourceType: "workspace", Action: policy.ActionDelete, Negate: true}}, + Site: []Permission{siteWildcard}, + User: []Permission{siteDeleteNo}, }, AllowIDList: []AllowListElement{AllowListAll()}, }, @@ -65,14 +78,8 @@ func TestCheckCoverable(t *testing.T) { wantErr: "grants org or user permissions", }, { - name: "NegativeSitePermission", - scope: Scope{ - Role: Role{Site: []Permission{ - {ResourceType: "workspace", Action: policy.WildcardSymbol}, - {ResourceType: "workspace", Action: policy.ActionDelete, Negate: true}, - }}, - AllowIDList: []AllowListElement{AllowListAll()}, - }, + name: "NegativeSitePermission", + scope: coverableScope(siteWildcard, siteDeleteNo), wantErr: "carries a negative permission", }, { @@ -89,17 +96,65 @@ func TestCheckCoverable(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - for _, side := range []string{coverageSideRequested, coverageSideAllowed} { - err := checkCoverable(test.scope, side, "test_scope") + // The opposite side is chosen so that a coverable scope under test + // reaches the comparison and answers true: a wildcard grant covers + // any request, and a workspace:read request is covered by any + // grant here. That keeps a guard error distinguishable from an + // ordinary uncovered result. + cleanGrant := namedScope{name: "clean_scope", scope: coverableScope(siteWildcard)} + cleanRequest := namedScope{name: "clean_scope", scope: coverableScope(siteRead)} + 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) } }) } } + +// TestScopesCoverAllowedNegativeDoesNotWiden is the case the guards were added +// for. An allowed scope granting every workspace action except delete must not +// answer a request for delete. Reading its Site permissions alone would, since +// the wildcard matches and the anti-grant sits in a field coverage never reads. +func TestScopesCoverAllowedNegativeDoesNotWiden(t *testing.T) { + t.Parallel() + + everythingExceptDelete := namedScope{ + name: "workspace_except_delete", + scope: Scope{ + Role: Role{ + Site: []Permission{siteWildcard}, + User: []Permission{siteDeleteNo}, + }, + AllowIDList: []AllowListElement{AllowListAll()}, + }, + } + wantDelete := namedScope{ + name: "workspace:delete", + scope: coverableScope(Permission{ResourceType: "workspace", Action: policy.ActionDelete}), + } + + got, err := scopesCoverExpanded([]namedScope{everythingExceptDelete}, wantDelete) + require.Error(t, err) + require.False(t, got) +} From 865eb9a56785dd737235fa613f8592e87909bff4 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 18 Aug 2026 23:38:47 +0000 Subject: [PATCH 07/33] refactor(coderd/rbac): share the alias table and name the canonical contract The knowledge of which spellings are backward-compatibility aliases lived in two switches, one in IsExternalScope and one in CanonicalScopeName, kept in step by discipline. Drift between them is asymmetric: a name the first accepts and the second does not rewrite is declared public and then fails to expand on every request naming it. Both now read one table, so they agree by construction, and an internal test walks that table asserting each alias is public, resolves to a public name, and resolves to one ExpandScope accepts. A third alias is covered the day it is added. ScopesCover stated "names must be canonical" in prose only, which is wrong for exactly the two inputs IsExternalScope accepts and ExpandScope does not. The parameters are now canonicalAllowed and canonicalRequested, so the requirement shows up in editor hints at every call site rather than only in a doc comment the caller may not have opened. Naming the parameters was chosen over canonicalizing inside ScopesCover. The single downstream caller already canonicalizes both sides in bulk before comparing, so absorbing the step would remove nothing from it while dissolving the distinction between a public spelling and a stored one at the layer that should hold it. --- coderd/rbac/scopes.go | 18 +++++++++++------- coderd/rbac/scopes_catalog.go | 25 ++++++++++++++++++------- coderd/rbac/scopes_internal_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 6b0638ae0b3..102984a93c9 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -324,21 +324,25 @@ func expandLowLevel(resource string, action policy.Action) Scope { // permissions, not names, so `coder:workspaces.access` covers `workspace:read` // and `coder:all` covers everything. // -// Names must be canonical (see CanonicalScopeName). An unknown name is an -// error rather than a false, since a caller cannot tell those two apart. +// Both sides must already be canonical, which the parameter names restate at +// every call site. Passing what IsExternalScope accepted is not enough: it +// admits the `all` and `application_connect` aliases, which are public +// spellings rather than expandable names, so canonicalize between validating a +// name and asking about its coverage. An unknown name is an error rather than +// a false, since a caller cannot tell those two apart. // // Coverage models site-level grants only. A scope carrying anything else is // refused on either side rather than compared on the part that is modeled, // because comparing a subset could report "covered" about authority that was // never examined. -func ScopesCover(allowed []ScopeName, requested ScopeName) (bool, error) { - want, err := ExpandScope(requested) +func ScopesCover(canonicalAllowed []ScopeName, canonicalRequested ScopeName) (bool, error) { + want, err := ExpandScope(canonicalRequested) if err != nil { return false, xerrors.Errorf("expand requested scope: %w", err) } - grants := make([]namedScope, 0, len(allowed)) - for _, name := range allowed { + grants := make([]namedScope, 0, len(canonicalAllowed)) + for _, name := range canonicalAllowed { expanded, err := ExpandScope(name) if err != nil { return false, xerrors.Errorf("expand allowed scope %q: %w", name, err) @@ -346,7 +350,7 @@ func ScopesCover(allowed []ScopeName, requested ScopeName) (bool, error) { grants = append(grants, namedScope{name: name, scope: expanded}) } - return scopesCoverExpanded(grants, namedScope{name: requested, scope: want}) + return scopesCoverExpanded(grants, namedScope{name: canonicalRequested, scope: want}) } // namedScope pairs an expanded scope with the name the caller spelled, so a diff --git a/coderd/rbac/scopes_catalog.go b/coderd/rbac/scopes_catalog.go index be129b204fe..03b783837dd 100644 --- a/coderd/rbac/scopes_catalog.go +++ b/coderd/rbac/scopes_catalog.go @@ -85,13 +85,27 @@ var externalComposite = map[ScopeName]struct{}{ "coder:apikeys.manage_self": {}, } +// 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, including the // `all` and `application_connect` special scopes and the curated // low-level resource:action scopes. 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 { @@ -113,11 +127,8 @@ func IsExternalScope(name ScopeName) bool { // `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 { - switch name { - case "all": - return ScopeAll - case "application_connect": - return ScopeApplicationConnect + if canonical, ok := scopeAliases[name]; ok { + return canonical } return name } diff --git a/coderd/rbac/scopes_internal_test.go b/coderd/rbac/scopes_internal_test.go index d57a4af1e01..301a9376ad5 100644 --- a/coderd/rbac/scopes_internal_test.go +++ b/coderd/rbac/scopes_internal_test.go @@ -23,6 +23,34 @@ func coverableScope(perms ...Permission) Scope { } } +// 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) + } +} + // 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. From 26a6bed5766575d018bb6a86291a832557f3afe7 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 18 Aug 2026 23:55:00 +0000 Subject: [PATCH 08/33] docs(coderd/rbac): document the expansion invariant where it can be broken The site-only, wildcard-allow-list, no-negatives invariant was described on ScopesCover and enforced by its guards, but ExpandScope, which is what produces those values, had no doc comment at all. Someone adding a scope reads ExpandScope and its neighbors; nothing there warned that populating User or adding a negative makes the scope uncomparable. State it there, along with the canonicalization requirement, and name the consequence rather than just the rule. Also note on ScopesCover that a wildcard request needs a wildcard grant. Enumerating today's concrete actions genuinely is narrower than `workspace:*`, so the rejection is intended. The OneActionDoesNotCoverResourceWildcard row already pins the behavior; the note stops the next reader of an authorize endpoint from taking it for a bug and closing the gap. Comments only. Checked that the documented invariant actually holds for all three builtin scopes and all seven composites. --- coderd/rbac/scopes.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 102984a93c9..c32243953a4 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -244,6 +244,16 @@ 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. Keep new scopes within that shape. ScopesCover depends +// on it, and a scope that breaks it becomes uncomparable: coverage refuses it +// on either side rather than answering from the fraction it does read. func ExpandScope(scope ScopeName) (Scope, error) { if role, ok := builtinScopes[scope]; ok { return role, nil @@ -324,6 +334,12 @@ func expandLowLevel(resource string, action policy.Action) Scope { // permissions, not names, so `coder:workspaces.access` covers `workspace:read` // and `coder:all` covers everything. // +// A wildcard request is covered only by a wildcard grant. Enumerating every +// workspace action that exists today does not cover `workspace:*`, because the +// wildcard also authorizes the actions added tomorrow. Rejecting a wildcard +// against an allowlist that looks exhaustive is the intended answer, not a gap +// to close. +// // Both sides must already be canonical, which the parameter names restate at // every call site. Passing what IsExternalScope accepted is not enough: it // admits the `all` and `application_connect` aliases, which are public From 1678a77a661a4d644e69abaf1c852fec8d3327cf Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 19 Aug 2026 18:22:08 +0000 Subject: [PATCH 09/33] test(coderd/rbac): pin the coverage guards at one strength TestScopesCoverAllowedNegativeDoesNotWiden drove the same scope shape as the NegativeUserPermission row of TestScopesCoverGuards, but asserted only that some error came back. The row asserts the message, the side it names, and that the comparison reports no coverage, and it runs the shape on both sides rather than one. The weaker copy could pass on a regression that returned the wrong error or stopped naming the side. Fold the scenario it documented into the row's comment and drop the copy. Rename the shared permission fixtures after the value they hold. The site prefix read as "belongs in Role.Site", while two of the three are placed in Role.User to build the shapes the guards refuse, and the No suffix gave no hint that it means Negate. Co-Authored-By: Claude Opus 5 --- coderd/rbac/scopes_internal_test.go | 61 +++++++++-------------------- 1 file changed, 18 insertions(+), 43 deletions(-) diff --git a/coderd/rbac/scopes_internal_test.go b/coderd/rbac/scopes_internal_test.go index 301a9376ad5..e661ba7d10c 100644 --- a/coderd/rbac/scopes_internal_test.go +++ b/coderd/rbac/scopes_internal_test.go @@ -9,9 +9,9 @@ import ( ) var ( - siteRead = Permission{ResourceType: "workspace", Action: policy.ActionRead} - siteWildcard = Permission{ResourceType: "workspace", Action: policy.WildcardSymbol} - siteDeleteNo = Permission{ResourceType: "workspace", Action: policy.ActionDelete, Negate: true} + 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 @@ -67,28 +67,30 @@ func TestScopesCoverGuards(t *testing.T) { }{ { name: "SitePermissionsOnly", - scope: coverableScope(siteRead), + scope: coverableScope(workspaceRead), }, { name: "UserPermission", scope: Scope{ Role: Role{ - Site: []Permission{siteRead}, - User: []Permission{siteRead}, + Site: []Permission{workspaceRead}, + User: []Permission{workspaceRead}, }, AllowIDList: []AllowListElement{AllowListAll()}, }, wantErr: "grants org or user permissions", }, { - // The permission coverage reads is harmless. The one it does not - // read carves an action back out, so comparing on Site alone would - // report authority the scope withholds. + // 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{siteWildcard}, - User: []Permission{siteDeleteNo}, + Site: []Permission{workspaceWildcard}, + User: []Permission{workspaceDeleteNegate}, }, AllowIDList: []AllowListElement{AllowListAll()}, }, @@ -98,7 +100,7 @@ func TestScopesCoverGuards(t *testing.T) { name: "OrgPermission", scope: Scope{ Role: Role{ - Site: []Permission{siteRead}, + Site: []Permission{workspaceRead}, ByOrgID: map[string]OrgPermissions{"00000000-0000-0000-0000-000000000001": {}}, }, AllowIDList: []AllowListElement{AllowListAll()}, @@ -107,13 +109,13 @@ func TestScopesCoverGuards(t *testing.T) { }, { name: "NegativeSitePermission", - scope: coverableScope(siteWildcard, siteDeleteNo), + scope: coverableScope(workspaceWildcard, workspaceDeleteNegate), wantErr: "carries a negative permission", }, { name: "NarrowedAllowList", scope: Scope{ - Role: Role{Site: []Permission{siteRead}}, + Role: Role{Site: []Permission{workspaceRead}}, AllowIDList: []AllowListElement{{Type: "workspace", ID: "00000000-0000-0000-0000-000000000002"}}, }, wantErr: "carries a resource allow list", @@ -129,8 +131,8 @@ func TestScopesCoverGuards(t *testing.T) { // any request, and a workspace:read request is covered by any // grant here. That keeps a guard error distinguishable from an // ordinary uncovered result. - cleanGrant := namedScope{name: "clean_scope", scope: coverableScope(siteWildcard)} - cleanRequest := namedScope{name: "clean_scope", scope: coverableScope(siteRead)} + 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 { @@ -159,30 +161,3 @@ func TestScopesCoverGuards(t *testing.T) { }) } } - -// TestScopesCoverAllowedNegativeDoesNotWiden is the case the guards were added -// for. An allowed scope granting every workspace action except delete must not -// answer a request for delete. Reading its Site permissions alone would, since -// the wildcard matches and the anti-grant sits in a field coverage never reads. -func TestScopesCoverAllowedNegativeDoesNotWiden(t *testing.T) { - t.Parallel() - - everythingExceptDelete := namedScope{ - name: "workspace_except_delete", - scope: Scope{ - Role: Role{ - Site: []Permission{siteWildcard}, - User: []Permission{siteDeleteNo}, - }, - AllowIDList: []AllowListElement{AllowListAll()}, - }, - } - wantDelete := namedScope{ - name: "workspace:delete", - scope: coverableScope(Permission{ResourceType: "workspace", Action: policy.ActionDelete}), - } - - got, err := scopesCoverExpanded([]namedScope{everythingExceptDelete}, wantDelete) - require.Error(t, err) - require.False(t, got) -} From aba3c6c0f78d08bc0a7a9d2b1428cbb257229ae0 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 19 Aug 2026 18:26:38 +0000 Subject: [PATCH 10/33] fix(coderd/rbac): name the scope once in expansion errors The allowed-side wrap printed the scope name and then wrapped an error that prints it again, so the two sides of one comparison read differently: expand allowed scope "foo": no scope named "foo" expand requested scope: no scope named "foo" Drop the redundant verb and let the inner error carry the name on both sides. Co-Authored-By: Claude Opus 5 --- coderd/rbac/scopes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index c32243953a4..8a38ee69388 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -361,7 +361,7 @@ func ScopesCover(canonicalAllowed []ScopeName, canonicalRequested ScopeName) (bo for _, name := range canonicalAllowed { expanded, err := ExpandScope(name) if err != nil { - return false, xerrors.Errorf("expand allowed scope %q: %w", name, err) + return false, xerrors.Errorf("expand allowed scope: %w", err) } grants = append(grants, namedScope{name: name, scope: expanded}) } From 9a081051b0f37b9db5fe7adb65d29bffa47f7cbc Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 19 Aug 2026 18:31:35 +0000 Subject: [PATCH 11/33] docs(coderd/rbac): correct the external scope list contract The docstring said the list includes the `all` and `application_connect` special scopes. It appends ScopeAll and ScopeApplicationConnect, which are the `coder:` spellings, so the bare aliases are absent. Two callers already compensate by appending them by hand, one of them with a comment stating the mismatch. Describe what the function returns and name the helper that bridges the gap. Co-Authored-By: Claude Opus 5 --- coderd/rbac/scopes_catalog.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/coderd/rbac/scopes_catalog.go b/coderd/rbac/scopes_catalog.go index 03b783837dd..5dc4e4d3e16 100644 --- a/coderd/rbac/scopes_catalog.go +++ b/coderd/rbac/scopes_catalog.go @@ -133,9 +133,14 @@ func CanonicalScopeName(name ScopeName) ScopeName { return name } -// 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. +// 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)) From 2fcce8b2d67271e006310cf3acbc9adf2a2d3225 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 19 Aug 2026 18:36:13 +0000 Subject: [PATCH 12/33] docs(coderd/rbac): trim the restated coverage invariant The invariant that expansion populates Site only was stated in full on ExpandScope, checkCoverable and ScopesCover, and the "everything except delete" example appeared on checkCoverable and again on permissionCovered twenty lines below. State it once on ScopesCover, which is the function whose behaviour depends on it, and cross-reference from the other two. Drop framing that ranked implementation choices nobody proposed, and cut the two test comments down to the facts the assertions do not already carry. Kept in full: what each guard in checkCoverable defends, since no other comment says it, and the wildcard rule on ScopesCover. Co-Authored-By: Claude Opus 5 --- coderd/rbac/scopes.go | 52 ++++++++++++----------------- coderd/rbac/scopes_internal_test.go | 7 ++-- coderd/rbac/scopes_test.go | 13 +++----- 3 files changed, 28 insertions(+), 44 deletions(-) diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 8a38ee69388..cdbc91523c8 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -251,9 +251,8 @@ func (s Scope) Name() RoleIdentifier { // with CanonicalScopeName first. // // Every expansion populates Site only, with a wildcard allow list and no -// negative permissions. Keep new scopes within that shape. ScopesCover depends -// on it, and a scope that breaks it becomes uncomparable: coverage refuses it -// on either side rather than answering from the fraction it does read. +// 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 @@ -334,23 +333,17 @@ func expandLowLevel(resource string, action policy.Action) Scope { // permissions, not names, so `coder:workspaces.access` covers `workspace:read` // and `coder:all` covers everything. // -// A wildcard request is covered only by a wildcard grant. Enumerating every -// workspace action that exists today does not cover `workspace:*`, because the -// wildcard also authorizes the actions added tomorrow. Rejecting a wildcard -// against an allowlist that looks exhaustive is the intended answer, not a gap -// to close. +// 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, which the parameter names restate at -// every call site. Passing what IsExternalScope accepted is not enough: it -// admits the `all` and `application_connect` aliases, which are public -// spellings rather than expandable names, so canonicalize between validating a -// name and asking about its coverage. An unknown name is an error rather than -// a false, since a caller cannot tell those two apart. +// 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. A scope carrying anything else is -// refused on either side rather than compared on the part that is modeled, -// because comparing a subset could report "covered" about authority that was -// never examined. +// 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) { want, err := ExpandScope(canonicalRequested) if err != nil { @@ -410,14 +403,12 @@ const ( ) // checkCoverable reports an error when scope carries authority that coverage -// cannot compare. Scope expansion populates Site only, with a wildcard allow -// list and no negative permissions, and coverage reads nothing else. A scope -// that breaks the invariant is refused rather than compared on its Site -// permissions alone, since the permissions left unread could be the ones that -// decide the answer: an org or user grant may itself carry a negative -// permission, and an "everything except delete" scope must not end up covering -// a request for delete. An allow list makes the Site permissions conditional, -// and reading them as unconditional would overstate the authority granted. +// 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 be skipped (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) @@ -436,11 +427,10 @@ func checkCoverable(scope Scope, side string, name ScopeName) error { // 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, because subsumption is the wrong -// question to ask about an anti-grant. Skipping a negative leaves any wildcard -// beside it free to match, so an "everything except delete" scope would read -// as covering delete, and honoring one as a grant would be worse still. +// 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 { for _, perm := range granted { if perm.ResourceType != needed.ResourceType && perm.ResourceType != policy.WildcardSymbol { diff --git a/coderd/rbac/scopes_internal_test.go b/coderd/rbac/scopes_internal_test.go index e661ba7d10c..118f57d1d46 100644 --- a/coderd/rbac/scopes_internal_test.go +++ b/coderd/rbac/scopes_internal_test.go @@ -126,11 +126,8 @@ func TestScopesCoverGuards(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - // The opposite side is chosen so that a coverable scope under test - // reaches the comparison and answers true: a wildcard grant covers - // any request, and a workspace:read request is covered by any - // grant here. That keeps a guard error distinguishable from an - // ordinary uncovered result. + // 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} diff --git a/coderd/rbac/scopes_test.go b/coderd/rbac/scopes_test.go index 0edf74d5fdd..79e3bf590e0 100644 --- a/coderd/rbac/scopes_test.go +++ b/coderd/rbac/scopes_test.go @@ -238,14 +238,11 @@ func TestCanonicalScopeName(t *testing.T) { func TestScopesCoverEveryExternalScope(t *testing.T) { t.Parallel() - // ExternalScopeNames yields canonical names only, so canonicalizing them - // returns them unchanged and the call below would pass through on every - // iteration. Appending the aliases asserts that a name a client may - // request is comparable once canonicalized, which is the positive half of - // the contract the alias rows in TestScopesCover assert the negative of. - // It does not pin the mapping itself: both aliases resolve to scopes that - // cover themselves, so a swapped mapping still satisfies this loop. - // TestCanonicalScopeName is what catches that. + // ExternalScopeNames yields canonical names only, so the aliases are + // appended to assert that every name a client may request is comparable + // once canonicalized. A swapped mapping still satisfies this loop, since + // both aliases resolve to scopes that cover themselves. + // TestCanonicalScopeName is what pins the mapping. names := append(rbac.ExternalScopeNames(), "all", "application_connect") for _, name := range names { From a775a482cd28ccb1c9a1d96d066cd7c16ea69764 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 19 Aug 2026 20:16:51 +0000 Subject: [PATCH 13/33] docs(coderd/rbac): correct the negative permission cross-reference checkCoverable said a negative site permission would be skipped, naming a branch permissionCovered no longer has. A negative reaching it matches on resource type and action like any other grant, so the anti-grant would read as a grant. Name that instead, so the cross-reference lands on a doc that matches the code. --- coderd/rbac/scopes.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index cdbc91523c8..74fb8713bce 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -406,9 +406,10 @@ const ( // 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 be skipped (see permissionCovered), and an allow list makes -// the Site permissions conditional, so reading them as unconditional would -// overstate what the scope grants. +// 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) From e0c0d4ac5d7bbd5589ad8a82351680a7be6255f6 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 19 Aug 2026 20:18:32 +0000 Subject: [PATCH 14/33] docs(coderd/rbac): name every category IsExternalScope admits The docstring listed the aliases and the low-level scopes, omitting the curated composites the function also accepts. A caller consulting it to decide whether coder:workspaces.access is public read no from the doc and yes from the code. --- coderd/rbac/scopes_catalog.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/coderd/rbac/scopes_catalog.go b/coderd/rbac/scopes_catalog.go index 5dc4e4d3e16..dd7f0871cca 100644 --- a/coderd/rbac/scopes_catalog.go +++ b/coderd/rbac/scopes_catalog.go @@ -97,9 +97,10 @@ var scopeAliases = map[ScopeName]ScopeName{ "application_connect": ScopeApplicationConnect, } -// IsExternalScope returns true if the scope is public, including the -// `all` and `application_connect` special scopes and the curated -// low-level resource:action scopes. +// 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 From 189740dc21566573255a81bc839eba054f92afa9 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 19 Aug 2026 20:20:07 +0000 Subject: [PATCH 15/33] test(coderd/rbac): pin the alias list invariants on the alias table ExternalScopeNames promises it offers each scope under one canonical spelling, and no test held it to that. TestScopesCoverEveryExternalScope appended the two aliases, but canonicalized them back into names the list already carries, so it re-ran assertions the list iteration had made and left the promise itself unpinned. Assert on the alias table instead: the list omits the alias and offers its canonical target. Every offered name is already proven coverable, so the aliases inherit coverage, and a third alias inherits both invariants the day it is added rather than needing a third hardcoded pair here. --- coderd/rbac/scopes_internal_test.go | 7 +++++++ coderd/rbac/scopes_test.go | 29 ++++++++++++----------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/coderd/rbac/scopes_internal_test.go b/coderd/rbac/scopes_internal_test.go index 118f57d1d46..bb20f63e2d6 100644 --- a/coderd/rbac/scopes_internal_test.go +++ b/coderd/rbac/scopes_internal_test.go @@ -48,6 +48,13 @@ func TestScopeAliases(t *testing.T) { // 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) } } diff --git a/coderd/rbac/scopes_test.go b/coderd/rbac/scopes_test.go index 79e3bf590e0..8edea8f2707 100644 --- a/coderd/rbac/scopes_test.go +++ b/coderd/rbac/scopes_test.go @@ -238,22 +238,17 @@ func TestCanonicalScopeName(t *testing.T) { func TestScopesCoverEveryExternalScope(t *testing.T) { t.Parallel() - // ExternalScopeNames yields canonical names only, so the aliases are - // appended to assert that every name a client may request is comparable - // once canonicalized. A swapped mapping still satisfies this loop, since - // both aliases resolve to scopes that cover themselves. - // TestCanonicalScopeName is what pins the mapping. - names := append(rbac.ExternalScopeNames(), "all", "application_connect") - - for _, name := range names { - canonical := rbac.CanonicalScopeName(rbac.ScopeName(name)) - - covered, err := rbac.ScopesCover([]rbac.ScopeName{rbac.ScopeAll}, canonical) - require.NoErrorf(t, err, "coder:all vs %q", canonical) - require.Truef(t, covered, "coder:all must cover %q", canonical) - - covered, err = rbac.ScopesCover([]rbac.ScopeName{canonical}, canonical) - require.NoErrorf(t, err, "%q vs itself", canonical) - require.Truef(t, covered, "%q must cover itself", canonical) + // The aliases inherit this without being named here: TestScopeAliases pins + // that each one canonicalizes to a name this list offers. + for _, name := range rbac.ExternalScopeNames() { + scope := rbac.ScopeName(name) + + covered, err := rbac.ScopesCover([]rbac.ScopeName{rbac.ScopeAll}, scope) + require.NoErrorf(t, err, "coder:all vs %q", scope) + require.Truef(t, covered, "coder:all must cover %q", scope) + + covered, err = rbac.ScopesCover([]rbac.ScopeName{scope}, scope) + require.NoErrorf(t, err, "%q vs itself", scope) + require.Truef(t, covered, "%q must cover itself", scope) } } From ca4dc522eb6ac82ef953843002ad9594b9bf2302 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 14 Aug 2026 17:40:08 +0000 Subject: [PATCH 16/33] feat(coderd/oauth2provider): negotiate and persist authorization scope The authorize endpoint parsed the scope parameter and discarded it, so an app's configured allowlist never restricted anything and a client asking for more than it should get was never told no. Phase 1 added the columns that carry a negotiated scope from a code to the token it becomes, but nothing wrote one, so every code was stamped unrestricted. Negotiate the scope at authorization time and persist the result: - Requested names must be in the external scope catalog, and the app's stored allowlist is filtered through that same catalog. Filtering only ever narrows what can be granted. - The allowlist bounds authority, not spelling. A request is granted when every permission it grants is also granted by the allowlist, whether or not the allowlist names it, so an app allowed coder:workspaces.access can approve a client asking only for workspace:ssh. - Omitting scope grants the filtered allowlist, per RFC 6749 section 3.3. - Both handlers negotiate, so a request that cannot succeed fails before the consent page renders rather than after the user clicks Allow. Each reports the failure the way it already reports its own errors: a static error page on the GET side, an OAuth2 error body on the POST side. - Two paths produce an empty result and are deliberately distinct. No allowlist and no request keeps the previous unrestricted grant, written as an explicit sentinel because the column is NOT NULL with a non-empty CHECK. An allowlist that filters to nothing is rejected, since falling back would grant strictly more than the allowlist ever permitted. Dynamic client registration performs no catalog validation, so apps registered with scopes such as openid or admin hold allowlists this server cannot grant from. They now fail authorization in both directions. Grandfathering unknown names would seed the enforcement path with values it cannot evaluate, trading a visible negotiation-time error for a silent enforcement-time hole. The failure names the registered scopes and the remedy. Issued tokens are still unrestricted: the exchange copies the negotiated scope onto the token record, but the API key it mints carries no scope. This changes which authorization requests succeed, not what a token can do. --- coderd/apidoc/docs.go | 4 +- coderd/apidoc/swagger.json | 4 +- coderd/oauth2.go | 4 +- coderd/oauth2_metadata_validation_test.go | 18 +- coderd/oauth2provider/authorize.go | 206 +++++++++- .../oauth2provider/authorize_internal_test.go | 316 +++++++++++++++ coderd/oauth2provider/authorize_test.go | 363 ++++++++++++++++++ coderd/oauth2provider/validation_test.go | 21 +- docs/reference/api/enterprise.md | 28 +- 9 files changed, 930 insertions(+), 34 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 03bbcbe1fe3..b2051a5eca2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14854,7 +14854,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Token scopes (currently ignored)", + "description": "Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted", "name": "scope", "in": "query" } @@ -14910,7 +14910,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Token scopes (currently ignored)", + "description": "Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted", "name": "scope", "in": "query" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b8bec92d829..d046441b9aa 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13203,7 +13203,7 @@ }, { "type": "string", - "description": "Token scopes (currently ignored)", + "description": "Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted", "name": "scope", "in": "query" } @@ -13254,7 +13254,7 @@ }, { "type": "string", - "description": "Token scopes (currently ignored)", + "description": "Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted", "name": "scope", "in": "query" } diff --git a/coderd/oauth2.go b/coderd/oauth2.go index 2e083eeca63..ac30bca8a7f 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -120,7 +120,7 @@ func (api *API) deleteOAuth2ProviderAppSecret() http.HandlerFunc { // @Param state query string true "A random unguessable string" // @Param response_type query codersdk.OAuth2ProviderResponseType true "Response type" // @Param redirect_uri query string false "Redirect here after authorization" -// @Param scope query string false "Token scopes (currently ignored)" +// @Param scope query string false "Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted" // @Success 200 "Returns HTML authorization page" // @Router /oauth2/authorize [get] func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc { @@ -135,7 +135,7 @@ func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc { // @Param state query string true "A random unguessable string" // @Param response_type query codersdk.OAuth2ProviderResponseType true "Response type" // @Param redirect_uri query string false "Redirect here after authorization" -// @Param scope query string false "Token scopes (currently ignored)" +// @Param scope query string false "Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted" // @Success 302 "Returns redirect with authorization code" // @Router /oauth2/authorize [post] func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc { diff --git a/coderd/oauth2_metadata_validation_test.go b/coderd/oauth2_metadata_validation_test.go index 01b2143f5a6..3bce27a8afd 100644 --- a/coderd/oauth2_metadata_validation_test.go +++ b/coderd/oauth2_metadata_validation_test.go @@ -541,7 +541,15 @@ func TestOAuth2ClientNameValidation(t *testing.T) { } } -// TestOAuth2ClientScopeValidation tests scope parameter validation +// TestOAuth2ClientScopeValidation tests scope parameter validation at +// registration time, which accepts any syntactically valid scope string. +// +// Registration performs no scope catalog validation, so these values are +// stored verbatim as the app's scope allowlist. Authorization is where the +// catalog is enforced: none of the names below is in rbac.IsExternalScope, so +// an app registered with one can no longer complete an authorization, whether +// it requests that scope or omits scope entirely. See +// TestOAuth2AuthorizeDCRScopeCompatibility in coderd/oauth2provider. func TestOAuth2ClientScopeValidation(t *testing.T) { t.Parallel() @@ -596,9 +604,11 @@ func TestOAuth2ClientScopeValidation(t *testing.T) { expectError: false, }, { - name: "InvalidAdmin", - scope: "admin", - expectError: false, // Admin scope should be allowed but validated during authorization + name: "InvalidAdmin", + scope: "admin", + // Registration accepts it; authorization rejects it with + // invalid_scope, since "admin" is not a grantable scope name. + expectError: false, }, { name: "ValidCustom", diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index d9396c20850..77fd66d4944 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -19,10 +19,177 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/site" ) +// Rejection reasons from validateRequestedScope. They are sentinels rather +// than inline messages so a caller, and the tests, can tell which check +// failed without matching on message text. +// +// Each is wrapped with the offending value ahead of it, because xerrors only +// wraps without repeating the sentinel's own text when %w is the final verb. +// These messages are rendered into error_description, so a doubled one is read +// by a person. +var ( + // errUnknownScope is returned for a scope name outside the external scope + // catalog, whether unrecognized entirely or recognized but internal-only. + errUnknownScope = xerrors.New("unknown or unsupported scope") + // errNoGrantableScope is returned when every entry of the app's allowlist + // falls outside the catalog, leaving nothing the app can be granted. The + // request is not at fault here and may have carried no scope at all, so + // the message names the registered list and the only remedy, which is + // re-registering the app. + errNoGrantableScope = xerrors.New("none of the scopes registered for this app are supported by this deployment; re-register the app with supported scopes") + // errScopeNotAllowed is returned for a catalog scope the app's allowlist + // does not cover. + errScopeNotAllowed = xerrors.New("scope is not in this app's allowed scope list") +) + +// canonicalScopes rewrites each name to the spelling the api_key_scope enum +// stores and drops repeats, preserving the order of first appearance. +// +// It neither validates nor filters: callers check rbac.IsExternalScope +// separately. Canonicalization is required because rbac.IsExternalScope +// accepts the aliases `all` and `application_connect`, which are not enum +// members, so persisting a validated name verbatim can write a value the +// column's vocabulary does not contain. Deduplicating here keeps the stored +// value set-valued, which is what a space-separated scope denotes. +func canonicalScopes(names []string) []string { + canonical := make([]string, 0, len(names)) + for _, name := range names { + canonical = append(canonical, string(rbac.CanonicalScopeName(rbac.ScopeName(name)))) + } + return slice.Unique(canonical) +} + +// noScopeAllowlist reports whether an app has no scope allowlist configured. +// NULL and "" are one state, and this is the only place the two are unified: +// admin-created apps store sql.NullString{} (apps.go), while DCR-registered +// apps store Valid: true carrying a possibly-empty req.Scope +// (registration.go). Once the allowlist decides what a token may do, reading +// it is an authorization decision, so the two encodings route through one +// predicate rather than each caller flattening via .String. +// +// A whitespace-only allowlist is deliberately not this state. It is a +// configured value that grants nothing, so it falls through to +// validateRequestedScope's filtered-to-empty rejection instead of the +// unrestricted fallback. +func noScopeAllowlist(appScope sql.NullString) bool { + return !appScope.Valid || appScope.String == "" +} + +// validateRequestedScope negotiates the scope the authorization code will +// carry. Every requested name must be in the external scope catalog (RFC 6749 +// §4.1.2.1 invalid_scope), and the request must be covered by the app's +// configured allowlist. +// +// What each branch returns: +// +// allowlist request result +// absent absent ApiKeyScopeCoderAll, the pre-enforcement grant +// absent present the request, which is narrower than unrestricted +// present absent the whole allowlist (RFC 6749 §3.3 default) +// present present the request, once shown to be within the allowlist +// +// An allowlist is absent when NULL or empty, which noScopeAllowlist treats as +// one state. An allowlist whose every entry falls outside the catalog is +// rejected rather than read as absent, since falling back there would grant +// strictly more than the allowlist ever permitted. +// +// The return value is written directly to a NOT NULL column whose CHECK +// constraint also rejects the empty string, so it is a string rather than a +// []string, and it is never empty alongside a nil error. Its names are +// canonical api_key_scope spellings and carry no duplicates, so the value can +// be stored as that enum without further rewriting. +func validateRequestedScope(requested []string, appScope sql.NullString) (string, error) { + // Only names in the external scope catalog (rbac.IsExternalScope) are + // user-requestable. That is a curation, not a validity check: RBAC can + // expand internal-only names such as debug_info:read just fine, and the + // api_key_scope enum would store them, which is exactly why the catalog + // exists as a narrower list. Checking here keeps both an unrecognizable + // name and an internal-only one out of the granted scope, whether or not + // the app has an allowlist to check against. + for _, s := range requested { + if !rbac.IsExternalScope(rbac.ScopeName(s)) { + return "", xerrors.Errorf("%q: %w", s, errUnknownScope) + } + } + + // Canonicalized after the catalog check, so a rejection names the scope + // as the client spelled it rather than as the server stores it. + granted := canonicalScopes(requested) + + if noScopeAllowlist(appScope) { + if len(requested) == 0 { + // Unrestricted, the same grant this app got before scope + // enforcement existed, but stated explicitly: an empty string + // would violate the column's CHECK. + return string(database.ApiKeyScopeCoderAll), nil + } + return strings.Join(granted, " "), nil + } + + // Filter the allowlist through IsExternalScope before it is used for + // anything. The allowlist was stored at registration time and may contain + // a scope name since removed from the curated catalog, or never in it at + // all. Filtering only ever narrows what is granted. + allowed := strings.Fields(appScope.String) + filtered := make([]string, 0, len(allowed)) + for _, a := range allowed { + if rbac.IsExternalScope(rbac.ScopeName(a)) { + filtered = append(filtered, a) + } + } + if len(filtered) == 0 { + // The app has an allowlist, but no entry in it is grantable. + // Returning the unrestricted sentinel here would grant strictly more + // than the allowlist ever permitted, so reject instead. This is the + // all-entries-dropped counterpart to the single-stale-entry case the + // filter above handles, and it must not share the no-allowlist + // branch's fallback. + // + // Named with the pre-filter list, since that is what was registered + // and what the app owner has to change. + return "", xerrors.Errorf("%q: %w", strings.Join(allowed, " "), errNoGrantableScope) + } + // Canonicalized so both sides expand: rbac.ExpandScope knows `coder:all` + // and not the `all` alias that IsExternalScope accepts. + filtered = canonicalScopes(filtered) + + if len(requested) == 0 { + return strings.Join(filtered, " "), nil // RFC 6749 §3.3 default + } + + // The allowlist is a ceiling on authority, not a menu of spellings, so the + // check is permission coverage rather than name membership. An app allowed + // `coder:workspaces.access` can approve a client asking only for + // `workspace:read`, which the composite already grants; under name + // matching that client's only route to a token was to request the broader + // composite instead. Coverage runs against the filtered allowlist, not the + // raw one, so a dropped entry grants nothing. + allowedNames := make([]rbac.ScopeName, 0, len(filtered)) + for _, a := range filtered { + allowedNames = append(allowedNames, rbac.ScopeName(a)) + } + for _, s := range granted { + covered, err := rbac.ScopesCover(allowedNames, rbac.ScopeName(s)) + if err != nil { + // Coverage could not be decided, so the request is refused rather + // than granted on an incomplete comparison. %w is last because + // xerrors repeats a wrapped message that is not, and this text is + // rendered into error_description for a person to read. + return "", xerrors.Errorf("%q (%v): %w", s, err, errScopeNotAllowed) + } + if !covered { + return "", xerrors.Errorf("%q: %w", s, errScopeNotAllowed) + } + } + return strings.Join(granted, " "), nil +} + type authorizeParams struct { clientID string redirectURL *url.URL @@ -156,6 +323,28 @@ func ShowAuthorizePage(accessURL *url.URL) http.HandlerFunc { return } + // Reject a scope the app can never be granted before the consent page + // renders, rather than after the user clicks Allow. Both handlers run + // the check for that reason: this one to decide whether the page + // renders at all, the POST side to persist the result. The two + // negotiate the same query string, since the consent form posts back + // to this URL. + if _, err := validateRequestedScope(params.scope, app.Scope); err != nil { + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusBadRequest, + HideStatus: false, + Title: "Invalid Scope", + Description: err.Error(), + Actions: []site.Action{ + { + URL: accessURL.String(), + Text: "Back to site", + }, + }, + }) + return + } + cancel := params.redirectURL cancelQuery := params.redirectURL.Query() cancelQuery.Add("error", "access_denied") @@ -234,7 +423,13 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc { return } - // TODO: Ignoring scope for now, but should look into implementing. + grantedScope, err := validateRequestedScope(params.scope, app.Scope) + if err != nil { + httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, + codersdk.OAuth2ErrorCodeInvalidScope, err.Error()) + return + } + code, err := GenerateSecret() if err != nil { httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Failed to generate OAuth2 app authorization code") @@ -271,11 +466,10 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc { CodeChallengeMethod: sql.NullString{String: params.codeChallengeMethod, Valid: params.codeChallengeMethod != ""}, StateHash: hashOAuth2State(params.state), RedirectUri: sql.NullString{String: params.redirectURL.String(), Valid: params.redirectURIProvided}, - // Scope negotiation lands in a later phase. Until the - // requested scope is validated against the app's allowlist, - // persisting it here would store unvalidated client input, so - // the code records an unrestricted grant. - Scope: string(database.ApiKeyScopeCoderAll), + // The negotiated scope, not the requested one: it has been + // checked against the scope catalog and the app's allowlist, + // and it is what the token minted from this code will carry. + Scope: grantedScope, }) if err != nil { return xerrors.Errorf("insert oauth2 authorization code: %w", err) diff --git a/coderd/oauth2provider/authorize_internal_test.go b/coderd/oauth2provider/authorize_internal_test.go index 4f2d3fc9937..09af9ccfdc8 100644 --- a/coderd/oauth2provider/authorize_internal_test.go +++ b/coderd/oauth2provider/authorize_internal_test.go @@ -2,13 +2,329 @@ package oauth2provider import ( "crypto/sha256" + "database/sql" "encoding/hex" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" ) +func TestValidateRequestedScope(t *testing.T) { + t.Parallel() + + // Every scope name below is either in rbac.IsExternalScope's curated + // catalog or deliberately outside it; the test's meaning depends on which, + // so they are named rather than inlined. + const ( + inCatalog = "coder:workspaces.access" + alsoInCatalog = "coder:templates.build" + notInCatalog = "some_removed_scope" + neverInCatalog = "openid" + ) + + noAllowlist := sql.NullString{} + emptyAllowlist := sql.NullString{String: "", Valid: true} + + // wantErr names the branch a rejection must come from. The three reasons + // are separately reachable and separately meaningful, so asserting only + // that some error occurred would let a refactor route one branch through + // another unnoticed. + tests := []struct { + name string + requested []string + appScope sql.NullString + want string + wantErr error + }{ + { + name: "UnknownRequestedScopeRejected", + requested: []string{"not_a_real_scope"}, + appScope: sql.NullString{String: inCatalog, Valid: true}, + wantErr: errUnknownScope, + }, + { + // The catalog check does not depend on the allowlist, so an + // unknown scope is rejected even where there is nothing to + // check it against. + name: "UnknownRequestedScopeRejectedWithoutAllowlist", + requested: []string{"not_a_real_scope"}, + appScope: noAllowlist, + wantErr: errUnknownScope, + }, + { + // A different rejection from the case above, and the one that + // matters more: debug_info:read is a real scope RBAC can expand + // and the api_key_scope enum can store. Only the catalog's + // curation keeps a client from negotiating an internal-only + // permission for itself. + name: "InternalOnlyScopeRejected", + requested: []string{"debug_info:read"}, + appScope: noAllowlist, + wantErr: errUnknownScope, + }, + { + // The literal return value matters. "" is exactly what + // the column's CHECK rejects, so asserting only "no error" would + // let a DB-level 500 through. + name: "NoAllowlistOmittedRequestIsUnrestricted", + requested: nil, + appScope: noAllowlist, + want: string(database.ApiKeyScopeCoderAll), + }, + { + // '' is the DCR-registered encoding of the same "no allowlist + // configured" state NULL expresses for admin-created apps. Both must reach the same branch. + name: "EmptyAllowlistBehavesAsNoAllowlist", + requested: nil, + appScope: emptyAllowlist, + want: string(database.ApiKeyScopeCoderAll), + }, + { + name: "NoAllowlistExplicitRequestPassesThrough", + requested: []string{inCatalog}, + appScope: noAllowlist, + want: inCatalog, + }, + { + name: "EmptyAllowlistExplicitRequestPassesThrough", + requested: []string{inCatalog}, + appScope: emptyAllowlist, + want: inCatalog, + }, + { + // RFC 6749 §3.3: an omitted scope defaults to the app's allowlist. + name: "OmittedRequestDefaultsToAllowlist", + requested: nil, + appScope: sql.NullString{String: inCatalog + " " + alsoInCatalog, Valid: true}, + want: inCatalog + " " + alsoInCatalog, + }, + { + name: "ExactMatchAccepted", + requested: []string{inCatalog}, + appScope: sql.NullString{String: inCatalog, Valid: true}, + want: inCatalog, + }, + { + name: "GenuineSubsetAccepted", + requested: []string{alsoInCatalog}, + appScope: sql.NullString{String: inCatalog + " " + alsoInCatalog, Valid: true}, + want: alsoInCatalog, + }, + { + // coder:workspaces.access grants template:read but not + // template:update, so the second name asks for authority the + // allowlist never carried. + name: "PartiallyOutOfAllowlistRejected", + requested: []string{inCatalog, "template:update"}, + appScope: sql.NullString{String: inCatalog, Valid: true}, + wantErr: errScopeNotAllowed, + }, + { + // The allowlist bounds authority, not spelling. A client asking + // for one permission the composite already grants gets a token + // narrower than the ceiling instead of being forced to request + // the whole composite to get any token at all. + name: "LowLevelScopeCoveredByCompositeAllowlistAccepted", + requested: []string{"workspace:ssh"}, + appScope: sql.NullString{String: inCatalog, Valid: true}, + want: "workspace:ssh", + }, + { + // Coverage is per requested name, so a request mixing a covered + // name with an uncovered one is refused whole rather than + // silently trimmed to the covered part. + name: "PartiallyCoveredRequestRejectedWhole", + requested: []string{"workspace:ssh", "workspace:delete"}, + appScope: sql.NullString{String: inCatalog, Valid: true}, + wantErr: errScopeNotAllowed, + }, + { + // The wildcard action is wider than the composite that covers + // its read half, so it is not covered by it. + name: "WildcardActionNotCoveredByCompositeAllowlist", + requested: []string{"workspace:*"}, + appScope: sql.NullString{String: inCatalog, Valid: true}, + wantErr: errScopeNotAllowed, + }, + { + // coder:all expands to the wildcard resource and action, so it + // is a ceiling over every requestable name. + name: "AllAllowlistCoversAnyScope", + requested: []string{"user_secret:delete"}, + appScope: sql.NullString{String: string(database.ApiKeyScopeCoderAll), Valid: true}, + want: "user_secret:delete", + }, + { + // Coverage reads the allowlist as one ceiling rather than + // checking each entry alone, so a request may draw on more than + // one entry at once. + name: "CoverageSpansMultipleAllowlistEntries", + requested: []string{"file:create", "workspace:ssh"}, + appScope: sql.NullString{String: inCatalog + " " + alsoInCatalog, Valid: true}, + want: "file:create workspace:ssh", + }, + { + // Catalog drift. The stale entry is dropped by the filter, and + // the surviving entry is still granted. + name: "StaleAllowlistEntryDroppedNotGranted", + requested: nil, + appScope: sql.NullString{String: inCatalog + " " + notInCatalog, Valid: true}, + want: inCatalog, + }, + { + // A dropped entry cannot be reached by requesting it explicitly + // either. The catalog check on the request rejects it before the + // allowlist is consulted at all, which is why the reason here is + // errUnknownScope and not errScopeNotAllowed. + name: "StaleAllowlistEntryNotRequestableExplicitly", + requested: []string{notInCatalog}, + appScope: sql.NullString{String: inCatalog + " " + notInCatalog, Valid: true}, + wantErr: errUnknownScope, + }, + { + // The all-entries-dropped counterpart to the case above. Falling back to the unrestricted sentinel here would + // grant strictly more than this allowlist ever permitted. + name: "AllowlistFilteringToEmptyRejected", + requested: nil, + appScope: sql.NullString{String: "openid profile email", Valid: true}, + wantErr: errNoGrantableScope, + }, + { + // The accepted compatibility break in its most direct form: a + // DCR client requesting exactly what it registered. + name: "NonCatalogScopeRequestedAsRegistered", + requested: []string{neverInCatalog}, + appScope: sql.NullString{String: neverInCatalog, Valid: true}, + wantErr: errUnknownScope, + }, + { + // A whitespace-only allowlist is a configured value that grants + // nothing, not an unset one, so it rejects rather than falling + // back to unrestricted. + name: "WhitespaceOnlyAllowlistRejected", + requested: nil, + appScope: sql.NullString{String: " ", Valid: true}, + wantErr: errNoGrantableScope, + }, + { + // rbac.IsExternalScope accepts `all` as a backward-compatible + // alias, but the api_key_scope enum has no such member, so + // persisting the requested spelling verbatim would store a value + // outside the column's vocabulary. + name: "LegacyAllAliasCanonicalized", + requested: []string{"all"}, + appScope: noAllowlist, + want: "coder:all", + }, + { + name: "LegacyApplicationConnectAliasCanonicalized", + requested: []string{"application_connect"}, + appScope: noAllowlist, + want: "coder:application_connect", + }, + { + // The allowlist is canonicalized on the same terms, so the two + // spellings of one scope match across the subset check rather + // than reading as different scopes. + name: "LegacyAliasInAllowlistCoversCanonicalRequest", + requested: []string{"coder:all"}, + appScope: sql.NullString{String: "all", Valid: true}, + want: "coder:all", + }, + { + name: "CanonicalAllowlistCoversLegacyAliasRequest", + requested: []string{"all"}, + appScope: sql.NullString{String: "coder:all", Valid: true}, + want: "coder:all", + }, + { + // A space-separated scope denotes a set, so a repeated request + // stores one entry rather than two. + name: "DuplicateRequestedScopesDeduplicated", + requested: []string{inCatalog, inCatalog}, + appScope: noAllowlist, + want: inCatalog, + }, + { + // The same holds for the RFC 6749 §3.3 default, which is built + // from the allowlist rather than from the request. + name: "DuplicateAllowlistEntriesDeduplicated", + requested: nil, + appScope: sql.NullString{String: inCatalog + " " + inCatalog, Valid: true}, + want: inCatalog, + }, + { + // Two spellings of one scope in the allowlist collapse to one + // entry, so the default does not name the same grant twice. + name: "AliasAndCanonicalAllowlistEntriesCollapse", + requested: nil, + appScope: sql.NullString{String: "all coder:all", Valid: true}, + want: "coder:all", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := validateRequestedScope(test.requested, test.appScope) + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + assert.Empty(t, got, "a rejected request must not return a persistable scope") + // This message is rendered into error_description and onto + // the authorize error page, so it is read by a person. + // xerrors repeats the wrapped text unless %w is the final + // verb, which is easy to reintroduce and invisible to + // errors.Is. + assert.Equal(t, 1, strings.Count(err.Error(), test.wantErr.Error()), + "the rejection reason must appear once, not doubled by the wrap") + return + } + require.NoError(t, err) + assert.Equal(t, test.want, got) + // The return value goes straight to a NOT NULL column carrying + // CHECK (scope <> ''), so an empty success is never legal. + assert.NotEmpty(t, got, "a successful negotiation must never return an empty scope") + requirePersistableScope(t, got) + }) + } +} + +// requirePersistableScope asserts that every name in a negotiated scope can +// survive the trip the value is about to take: stored as api_key_scope on the +// authorization code, carried to the token, and expanded by RBAC when the key +// minted from it is authorized. A name that passes the external scope catalog +// is not automatically one that clears all three, which is why this is +// asserted on the result rather than assumed from the input. +func requirePersistableScope(t *testing.T, scope string) { + t.Helper() + + for _, name := range strings.Fields(scope) { + require.Contains(t, database.AllAPIKeyScopeValues(), database.APIKeyScope(name), + "scope %q is not an api_key_scope member, so the column cannot store it", name) + + _, err := rbac.ExpandScope(rbac.ScopeName(name)) + require.NoError(t, err, "scope %q cannot be expanded by RBAC, so it cannot be enforced", name) + } +} + +func TestNoScopeAllowlist(t *testing.T) { + t.Parallel() + + // NULL and '' are one state. Both are produced in the tree today: + // sql.NullString{} by admin-created apps, Valid-with-empty-string by DCR + // registration that sent no scope. + assert.True(t, noScopeAllowlist(sql.NullString{})) + assert.True(t, noScopeAllowlist(sql.NullString{String: "", Valid: true})) + assert.False(t, noScopeAllowlist(sql.NullString{String: "coder:workspaces.access", Valid: true})) + assert.False(t, noScopeAllowlist(sql.NullString{String: " ", Valid: true})) +} + func TestHashOAuth2State(t *testing.T) { t.Parallel() diff --git a/coderd/oauth2provider/authorize_test.go b/coderd/oauth2provider/authorize_test.go index 61e037a8a4b..27004c02b04 100644 --- a/coderd/oauth2provider/authorize_test.go +++ b/coderd/oauth2provider/authorize_test.go @@ -1,15 +1,27 @@ package oauth2provider_test import ( + "context" + "database/sql" htmltemplate "html/template" + "io" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/oauth2provider" + "github.com/coder/coder/v2/coderd/oauth2provider/oauth2providertest" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/site" + "github.com/coder/coder/v2/testutil" ) func TestOAuthConsentFormIncludesCSRFToken(t *testing.T) { @@ -34,3 +46,354 @@ func TestOAuthConsentFormIncludesCSRFToken(t *testing.T) { assert.Contains(t, body, `id="allow-form"`) assert.Contains(t, body, `id="cancel-link"`) } + +// Scope names used by the negotiation tests. Whether a name is in +// rbac.IsExternalScope's curated catalog is the point of each case, so the two +// groups are named rather than inlined. +const ( + scopeInCatalog = "coder:workspaces.access" + scopeAlsoInCatalog = "coder:templates.build" + scopeOutOfCatalog = "some_removed_scope" + // In the catalog, and outside the authority scopeInCatalog carries: that + // composite grants template:read but never template:update. + scopeOutOfAllowlist = "template:update" +) + +// The callback every app in these tests registers, and the state every request +// sends. A rejection redirects to the first carrying the second, so both are +// named rather than inlined. +const ( + appCallbackURL = "https://example.com/callback" + authorizeState = "test-authorize-state" +) + +func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }) + _ = coderdtest.CreateFirstUser(t, client) + + // Each sub-test gets its own app: only one code exists per app/user pair at + // a time, and the allowlist is the variable under test. + seedApp := func(t *testing.T, appScope sql.NullString) database.OAuth2ProviderApp { + t.Helper() + return dbgen.OAuth2ProviderApp(t, db, database.OAuth2ProviderApp{ + Name: testutil.GetRandomName(t), + CallbackURL: appCallbackURL, + Scope: appScope, + }) + } + + t.Run("OutOfAllowlistRejected", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedApp(t, sql.NullString{String: scopeInCatalog, Valid: true}) + resp := authorizeRequest(ctx, t, client, http.MethodPost, app.ID.String(), scopeInCatalog+" "+scopeOutOfAllowlist) + defer resp.Body.Close() + + requireInvalidScope(t, resp, reasonScopeNotAllowed) + }) + + // The allowlist bounds authority rather than spelling, so a name it never + // lists is still granted when the permissions it expands to are ones the + // allowlist already carries. + t.Run("ScopeCoveredByAllowlistGranted", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedApp(t, sql.NullString{String: scopeInCatalog, Valid: true}) + resp := authorizeRequest(ctx, t, client, http.MethodPost, app.ID.String(), "workspace:ssh") + defer resp.Body.Close() + + require.Equal(t, "workspace:ssh", persistedCodeScope(ctx, t, db, resp)) + }) + + // The catalog half of the same guarantee: a scope name the enforcement + // layer cannot evaluate is rejected on its own terms, not because of the + // allowlist. + t.Run("UnknownScopeRejected", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedApp(t, sql.NullString{String: scopeInCatalog, Valid: true}) + resp := authorizeRequest(ctx, t, client, http.MethodPost, app.ID.String(), "not_a_real_scope") + defer resp.Body.Close() + + requireInvalidScope(t, resp, reasonUnknownScope) + }) + + // Omitting scope grants the app's full allowlist (RFC 6749 §3.3). + t.Run("OmittedScopeDefaultsToAllowlist", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + allowlist := scopeInCatalog + " " + scopeAlsoInCatalog + app := seedApp(t, sql.NullString{String: allowlist, Valid: true}) + resp := authorizeRequest(ctx, t, client, http.MethodPost, app.ID.String(), "") + defer resp.Body.Close() + + require.Equal(t, allowlist, persistedCodeScope(ctx, t, db, resp)) + }) + + // rbac.IsExternalScope accepts `all` as a backward-compatible alias, but + // the api_key_scope enum has only `coder:all`. Asserted against the stored + // row rather than the negotiation's return value, because the column's + // vocabulary is what the claim is about. + t.Run("LegacyAliasPersistedCanonically", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedApp(t, sql.NullString{}) + resp := authorizeRequest(ctx, t, client, http.MethodPost, app.ID.String(), "all") + defer resp.Body.Close() + + require.Equal(t, string(database.ApiKeyScopeCoderAll), persistedCodeScope(ctx, t, db, resp)) + }) + + // A repeated scope denotes one grant, so it is stored once. + t.Run("DuplicateRequestedScopePersistedOnce", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedApp(t, sql.NullString{String: scopeInCatalog, Valid: true}) + resp := authorizeRequest(ctx, t, client, http.MethodPost, app.ID.String(), scopeInCatalog+" "+scopeInCatalog) + defer resp.Body.Close() + + require.Equal(t, scopeInCatalog, persistedCodeScope(ctx, t, db, resp)) + }) + + // Apps with no configured allowlist keep today's unrestricted behavior. The persisted value is asserted literally, since '' is what the + // column's CHECK would reject. + t.Run("NoAllowlistStaysUnrestricted", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedApp(t, sql.NullString{}) + resp := authorizeRequest(ctx, t, client, http.MethodPost, app.ID.String(), "") + defer resp.Body.Close() + + require.Equal(t, string(database.ApiKeyScopeCoderAll), persistedCodeScope(ctx, t, db, resp)) + }) + + // NULL (admin-created apps) and '' (DCR apps that sent no scope) are one + // "no allowlist configured" state and must behave identically. + t.Run("NullAndEmptyAllowlistBehaveIdentically", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + nullApp := seedApp(t, sql.NullString{}) + emptyApp := seedApp(t, sql.NullString{String: "", Valid: true}) + + nullResp := authorizeRequest(ctx, t, client, http.MethodPost, nullApp.ID.String(), "") + defer nullResp.Body.Close() + emptyResp := authorizeRequest(ctx, t, client, http.MethodPost, emptyApp.ID.String(), "") + defer emptyResp.Body.Close() + + nullScope := persistedCodeScope(ctx, t, db, nullResp) + emptyScope := persistedCodeScope(ctx, t, db, emptyResp) + require.Equal(t, string(database.ApiKeyScopeCoderAll), nullScope) + require.Equal(t, nullScope, emptyScope) + }) + + // An allowlist entry no longer in the catalog is dropped, not granted. Paired with AllowlistFilteringToEmptyRejected below, which is the + // same filter with no survivors. + t.Run("StaleAllowlistEntryDropped", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedApp(t, sql.NullString{String: scopeInCatalog + " " + scopeOutOfCatalog, Valid: true}) + resp := authorizeRequest(ctx, t, client, http.MethodPost, app.ID.String(), "") + defer resp.Body.Close() + + require.Equal(t, scopeInCatalog, persistedCodeScope(ctx, t, db, resp)) + }) + + // An allowlist whose every entry is dropped rejects rather than falling + // back to unrestricted, which would grant strictly more than the + // allowlist ever permitted. + t.Run("AllowlistFilteringToEmptyRejected", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedApp(t, sql.NullString{String: "openid profile email", Valid: true}) + resp := authorizeRequest(ctx, t, client, http.MethodPost, app.ID.String(), "") + defer resp.Body.Close() + + requireInvalidScope(t, resp, reasonNoGrantableScope) + }) +} + +// TestOAuth2AuthorizeDCRScopeCompatibility pins an accepted compatibility +// break: dynamic client registration performs no catalog validation, so an +// app can register an allowlist this server cannot grant from. Both +// directions fail, and both fail loudly with invalid_scope rather than +// silently granting a scope dbauthz has no way to evaluate. +func TestOAuth2AuthorizeDCRScopeCompatibility(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + oauth2providertest.EnableDCR(t, client) + + ctx := testutil.Context(t, testutil.WaitLong) + registration, err := client.PostOAuth2ClientRegistration(ctx, codersdk.OAuth2ClientRegistrationRequest{ + RedirectURIs: []string{appCallbackURL}, + ClientName: testutil.GetRandomName(t), + Scope: "openid profile email", + }) + require.NoError(t, err, "registration itself is unchanged: no catalog check happens here") + + t.Run("RequestingRegisteredScopeRejected", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + resp := authorizeRequest(ctx, t, client, http.MethodPost, registration.ClientID, "openid") + defer resp.Body.Close() + + requireInvalidScope(t, resp, reasonUnknownScope) + }) + + t.Run("OmittingScopeRejected", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + resp := authorizeRequest(ctx, t, client, http.MethodPost, registration.ClientID, "") + defer resp.Body.Close() + + requireInvalidScope(t, resp, reasonNoGrantableScope) + }) + + // The break is only recoverable by whoever registered the app, so the + // description has to name the scopes they registered: the request that + // triggered this carried none, and the registered list is what they have + // to change. + t.Run("RejectionNamesTheRegisteredScopes", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + resp := authorizeRequest(ctx, t, client, http.MethodGet, registration.ClientID, "") + defer resp.Body.Close() + body := requireInvalidScope(t, resp, reasonNoGrantableScope) + + require.Contains(t, body, "openid profile email", + "the app owner cannot act on this without knowing which registered scopes are the problem") + }) +} + +// authorizeQuery builds a well-formed /oauth2/authorize query. Callers that +// need to vary a parameter the happy path does not, such as redirect_uri, +// mutate the result and pass it to sendAuthorizeRequest. +func authorizeQuery(t *testing.T, clientID, scope string) url.Values { + t.Helper() + + _, challenge := oauth2providertest.GeneratePKCE(t) + query := url.Values{} + query.Set("client_id", clientID) + query.Set("response_type", "code") + query.Set("state", authorizeState) + query.Set("code_challenge", challenge) + query.Set("code_challenge_method", "S256") + if scope != "" { + query.Set("scope", scope) + } + return query +} + +// authorizeRequest issues an /oauth2/authorize request for the given app. +// Redirects are not followed, so a successful POST surfaces as a 302 whose +// Location carries the code. +func authorizeRequest(ctx context.Context, t *testing.T, client *codersdk.Client, method, clientID, scope string) *http.Response { + t.Helper() + + return sendAuthorizeRequest(ctx, t, client, method, authorizeQuery(t, clientID, scope)) +} + +func sendAuthorizeRequest(ctx context.Context, t *testing.T, client *codersdk.Client, method string, query url.Values) *http.Response { + t.Helper() + + authURL, err := url.Parse(client.URL.String() + "/oauth2/authorize") + require.NoError(t, err) + authURL.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, method, authURL.String(), nil) + require.NoError(t, err) + req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + + httpClient := &http.Client{ + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := httpClient.Do(req) + require.NoError(t, err) + return resp +} + +// persistedCodeScope follows a successful authorization to the code it issued +// and returns the scope recorded on that row, which is what the token exchange +// will later read. +func persistedCodeScope(ctx context.Context, t *testing.T, db database.Store, resp *http.Response) string { + t.Helper() + + require.Equal(t, http.StatusFound, resp.StatusCode) + location, err := url.Parse(resp.Header.Get("Location")) + require.NoError(t, err) + + formatted := location.Query().Get("code") + require.NotEmpty(t, formatted, "authorization did not issue a code") + + parsed, err := oauth2provider.ParseFormattedSecret(formatted) + require.NoError(t, err) + + code, err := db.GetOAuth2ProviderAppCodeByPrefix(ctx, []byte(parsed.Prefix)) + require.NoError(t, err) + return code.Scope +} + +// Fragments of the rejection reasons in authorize.go, each unique to one +// branch. The transport carries only the rendered description, so these pin +// over the wire what errors.Is pins in the package's own tests. +const ( + reasonUnknownScope = "unknown or unsupported scope" + reasonNoGrantableScope = "none of the scopes registered for this app are supported" + reasonScopeNotAllowed = "not in this app's allowed scope list" +) + +// requireInvalidScope asserts that a rejected request is refused rather than +// issued a code, and that the refusal names the branch the caller expects. +// +// Each handler answers in the form it already uses for its own errors: the GET +// side renders a static error page, since a person is looking at a browser, +// and the POST side writes an OAuth2 error body. Both carry the description in +// the response and neither redirects, so the assertion is on the status and +// the body rather than on a Location. Delivering these to the client's own +// callback, which is what RFC 6749 §4.1.2.1 actually calls for, is a separate +// change; this helper is rewritten there. +// +// The body is returned because reading it consumes it, so a caller asserting +// anything further has to work from this copy. +func requireInvalidScope(t *testing.T, resp *http.Response, wantReason string) string { + t.Helper() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + require.Empty(t, resp.Header.Get("Location"), + "a rejected request must not be redirected anywhere, least of all with a code") + + body := readBody(t, resp) + require.Contains(t, body, wantReason, + "the rejection must come from the branch this case covers") + return body +} + +func readBody(t *testing.T, resp *http.Response) string { + t.Helper() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return string(body) +} diff --git a/coderd/oauth2provider/validation_test.go b/coderd/oauth2provider/validation_test.go index 2bb442ab3c1..d7164eadec6 100644 --- a/coderd/oauth2provider/validation_test.go +++ b/coderd/oauth2provider/validation_test.go @@ -541,7 +541,18 @@ func TestOAuth2ClientNameValidation(t *testing.T) { } } -// TestOAuth2ClientScopeValidation tests scope parameter validation +// TestOAuth2ClientScopeValidation tests scope parameter validation at +// registration time, which accepts any syntactically valid scope string. +// +// Registration performs no scope catalog validation, so the values below are +// stored verbatim as the app's scope allowlist. Authorization is where the +// catalog is enforced: a name outside rbac.IsExternalScope cannot be granted, +// so an app registered with only such names can no longer complete an +// authorization in either direction. Requesting one is rejected with +// invalid_scope, and omitting scope entirely is rejected too, because the +// allowlist filters to nothing. TestOAuth2AuthorizeDCRScopeCompatibility +// covers both. Every non-empty scope below is in that position: none of read, +// write, openid, profile, email, admin, or custom:scope is in the catalog. func TestOAuth2ClientScopeValidation(t *testing.T) { t.Parallel() @@ -596,9 +607,11 @@ func TestOAuth2ClientScopeValidation(t *testing.T) { expectError: false, }, { - name: "InvalidAdmin", - scope: "admin", - expectError: false, // Admin scope should be allowed but validated during authorization + name: "InvalidAdmin", + scope: "admin", + // Registration accepts it; authorization rejects it with + // invalid_scope, since "admin" is not a grantable scope name. + expectError: false, }, { name: "ValidCustom", diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index aada5a73777..faef045da3b 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -4782,13 +4782,13 @@ curl -X GET http://coder-server:8080/oauth2/authorize?client_id=string&state=str ### Parameters -| Name | In | Type | Required | Description | -|-----------------|-------|--------|----------|-----------------------------------| -| `client_id` | query | string | true | Client ID | -| `state` | query | string | true | A random unguessable string | -| `response_type` | query | string | true | Response type | -| `redirect_uri` | query | string | false | Redirect here after authorization | -| `scope` | query | string | false | Token scopes (currently ignored) | +| Name | In | Type | Required | Description | +|-----------------|-------|--------|----------|---------------------------------------------------------------------------------------------------------------------------------| +| `client_id` | query | string | true | Client ID | +| `state` | query | string | true | A random unguessable string | +| `response_type` | query | string | true | Response type | +| `redirect_uri` | query | string | false | Redirect here after authorization | +| `scope` | query | string | false | Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted | #### Enumerated Values @@ -4818,13 +4818,13 @@ curl -X POST http://coder-server:8080/oauth2/authorize?client_id=string&state=st ### Parameters -| Name | In | Type | Required | Description | -|-----------------|-------|--------|----------|-----------------------------------| -| `client_id` | query | string | true | Client ID | -| `state` | query | string | true | A random unguessable string | -| `response_type` | query | string | true | Response type | -| `redirect_uri` | query | string | false | Redirect here after authorization | -| `scope` | query | string | false | Token scopes (currently ignored) | +| Name | In | Type | Required | Description | +|-----------------|-------|--------|----------|---------------------------------------------------------------------------------------------------------------------------------| +| `client_id` | query | string | true | Client ID | +| `state` | query | string | true | A random unguessable string | +| `response_type` | query | string | true | Response type | +| `redirect_uri` | query | string | false | Redirect here after authorization | +| `scope` | query | string | false | Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted | #### Enumerated Values From 09bc1b4e8584888058d5c1662f8fc30bed427066 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 21 Aug 2026 00:40:02 +0000 Subject: [PATCH 17/33] refactor(coderd/oauth2provider): rename validateRequestedScope to negotiateScope The function does not check a requested scope and hand back a verdict. It decides what scope the code will carry, which for an omitted request is the app's allowlist and for an app with no allowlist is coder:all. Neither is a value the caller asked for, so the name promised the wrong thing. --- coderd/oauth2provider/authorize.go | 24 +++++++++---------- .../oauth2provider/authorize_internal_test.go | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index 77fd66d4944..ebd96b56413 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -25,9 +25,9 @@ import ( "github.com/coder/coder/v2/site" ) -// Rejection reasons from validateRequestedScope. They are sentinels rather -// than inline messages so a caller, and the tests, can tell which check -// failed without matching on message text. +// Rejection reasons from negotiateScope. They are sentinels rather than inline +// messages so a caller, and the tests, can tell which check failed without +// matching on message text. // // Each is wrapped with the offending value ahead of it, because xerrors only // wraps without repeating the sentinel's own text when %w is the final verb. @@ -75,16 +75,16 @@ func canonicalScopes(names []string) []string { // // A whitespace-only allowlist is deliberately not this state. It is a // configured value that grants nothing, so it falls through to -// validateRequestedScope's filtered-to-empty rejection instead of the -// unrestricted fallback. +// negotiateScope's filtered-to-empty rejection instead of the unrestricted +// fallback. func noScopeAllowlist(appScope sql.NullString) bool { return !appScope.Valid || appScope.String == "" } -// validateRequestedScope negotiates the scope the authorization code will -// carry. Every requested name must be in the external scope catalog (RFC 6749 -// §4.1.2.1 invalid_scope), and the request must be covered by the app's -// configured allowlist. +// negotiateScope decides the scope the authorization code will carry. Every +// requested name must be in the external scope catalog (RFC 6749 §4.1.2.1 +// invalid_scope), and the request must be covered by the app's configured +// allowlist. // // What each branch returns: // @@ -104,7 +104,7 @@ func noScopeAllowlist(appScope sql.NullString) bool { // []string, and it is never empty alongside a nil error. Its names are // canonical api_key_scope spellings and carry no duplicates, so the value can // be stored as that enum without further rewriting. -func validateRequestedScope(requested []string, appScope sql.NullString) (string, error) { +func negotiateScope(requested []string, appScope sql.NullString) (string, error) { // Only names in the external scope catalog (rbac.IsExternalScope) are // user-requestable. That is a curation, not a validity check: RBAC can // expand internal-only names such as debug_info:read just fine, and the @@ -329,7 +329,7 @@ func ShowAuthorizePage(accessURL *url.URL) http.HandlerFunc { // renders at all, the POST side to persist the result. The two // negotiate the same query string, since the consent form posts back // to this URL. - if _, err := validateRequestedScope(params.scope, app.Scope); err != nil { + if _, err := negotiateScope(params.scope, app.Scope); err != nil { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusBadRequest, HideStatus: false, @@ -423,7 +423,7 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc { return } - grantedScope, err := validateRequestedScope(params.scope, app.Scope) + grantedScope, err := negotiateScope(params.scope, app.Scope) if err != nil { httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidScope, err.Error()) diff --git a/coderd/oauth2provider/authorize_internal_test.go b/coderd/oauth2provider/authorize_internal_test.go index 09af9ccfdc8..a001d27458b 100644 --- a/coderd/oauth2provider/authorize_internal_test.go +++ b/coderd/oauth2provider/authorize_internal_test.go @@ -14,7 +14,7 @@ import ( "github.com/coder/coder/v2/coderd/rbac" ) -func TestValidateRequestedScope(t *testing.T) { +func TestNegotiateScope(t *testing.T) { t.Parallel() // Every scope name below is either in rbac.IsExternalScope's curated @@ -272,7 +272,7 @@ func TestValidateRequestedScope(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - got, err := validateRequestedScope(test.requested, test.appScope) + got, err := negotiateScope(test.requested, test.appScope) if test.wantErr != nil { require.ErrorIs(t, err, test.wantErr) assert.Empty(t, got, "a rejected request must not return a persistable scope") From 6fe6c7100ca41248b57e2cc5f37ede6d86025bc9 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 21 Aug 2026 00:42:04 +0000 Subject: [PATCH 18/33] test(coderd/oauth2provider): bind the wire-level scope reasons to the sentinels The black-box tests assert on the description that reaches the client, and they did so through hand-copied fragments of the sentinel messages. A reworded sentinel would leave every case asserting on text no branch produces, and each case would still pass through whichever branch happened to match next. The sentinels live in package oauth2provider and the tests live in oauth2provider_test, so they are bound through exported values declared in the package's internal test file, which compiles into the same binary. --- coderd/oauth2provider/authorize_internal_test.go | 11 +++++++++++ coderd/oauth2provider/authorize_test.go | 16 +++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/coderd/oauth2provider/authorize_internal_test.go b/coderd/oauth2provider/authorize_internal_test.go index a001d27458b..1b757719f32 100644 --- a/coderd/oauth2provider/authorize_internal_test.go +++ b/coderd/oauth2provider/authorize_internal_test.go @@ -313,6 +313,17 @@ func requirePersistableScope(t *testing.T, scope string) { } } +// Rejection reasons handed to the package's black-box tests, which sit in +// oauth2provider_test and so cannot reach the sentinels themselves. They +// assert on what reaches the client, and the sentinel text is what reaches +// it, so binding here beats re-typing the strings over there: a rewording +// then moves both together instead of silently unpinning the branch mapping. +var ( + ReasonUnknownScope = errUnknownScope.Error() + ReasonNoGrantableScope = errNoGrantableScope.Error() + ReasonScopeNotAllowed = errScopeNotAllowed.Error() +) + func TestNoScopeAllowlist(t *testing.T) { t.Parallel() diff --git a/coderd/oauth2provider/authorize_test.go b/coderd/oauth2provider/authorize_test.go index 27004c02b04..dc9245d3fcf 100644 --- a/coderd/oauth2provider/authorize_test.go +++ b/coderd/oauth2provider/authorize_test.go @@ -355,13 +355,15 @@ func persistedCodeScope(ctx context.Context, t *testing.T, db database.Store, re return code.Scope } -// Fragments of the rejection reasons in authorize.go, each unique to one -// branch. The transport carries only the rendered description, so these pin -// over the wire what errors.Is pins in the package's own tests. -const ( - reasonUnknownScope = "unknown or unsupported scope" - reasonNoGrantableScope = "none of the scopes registered for this app are supported" - reasonScopeNotAllowed = "not in this app's allowed scope list" +// The rejection reasons from authorize.go, each unique to one branch. The +// transport carries only the rendered description, so these pin over the wire +// what errors.Is pins in the package's own tests. They are bound to the +// sentinels rather than re-typed as substrings, so rewording one cannot leave +// a case asserting on text no branch produces any more. +var ( + reasonUnknownScope = oauth2provider.ReasonUnknownScope + reasonNoGrantableScope = oauth2provider.ReasonNoGrantableScope + reasonScopeNotAllowed = oauth2provider.ReasonScopeNotAllowed ) // requireInvalidScope asserts that a rejected request is refused rather than From e296ddab0fe10237ef368d6f3e97a4e0d970c085 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 21 Aug 2026 00:44:47 +0000 Subject: [PATCH 19/33] refactor(coderd): log undecidable oauth2 scope coverage instead of returning it rbac.ScopesCover reports an error when it cannot expand one of the names it was handed. That is a deployment-side condition: the app's stored allowlist holds something RBAC will not resolve, and no client can fix it by asking differently. Folding it into errScopeNotAllowed both told the client it had asked for too much, which is not what happened, and rendered RBAC internals into error_description. The failure now goes to the log with the app that provoked it, and the client receives a sentinel of its own. negotiateScope takes the whole app rather than its scope alone so the log line can name it. --- coderd/oauth2.go | 4 +- coderd/oauth2provider/authorize.go | 40 ++++++++++++++----- .../oauth2provider/authorize_internal_test.go | 5 ++- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/coderd/oauth2.go b/coderd/oauth2.go index ac30bca8a7f..df7e281b736 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -124,7 +124,7 @@ func (api *API) deleteOAuth2ProviderAppSecret() http.HandlerFunc { // @Success 200 "Returns HTML authorization page" // @Router /oauth2/authorize [get] func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc { - return oauth2provider.ShowAuthorizePage(api.AccessURL) + return oauth2provider.ShowAuthorizePage(api.AccessURL, api.Logger) } // @Summary OAuth2 authorization request (POST - process authorization). @@ -139,7 +139,7 @@ func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc { // @Success 302 "Returns redirect with authorization code" // @Router /oauth2/authorize [post] func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc { - return oauth2provider.ProcessAuthorize(api.Database) + return oauth2provider.ProcessAuthorize(api.Database, api.Logger) } // @Summary OAuth2 token exchange. diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index ebd96b56413..8c61f8f7efe 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -1,6 +1,7 @@ package oauth2provider import ( + "context" "crypto/sha256" "database/sql" "encoding/hex" @@ -15,6 +16,7 @@ import ( "github.com/justinas/nosurf" "golang.org/x/xerrors" + "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" @@ -46,6 +48,12 @@ var ( // errScopeNotAllowed is returned for a catalog scope the app's allowlist // does not cover. errScopeNotAllowed = xerrors.New("scope is not in this app's allowed scope list") + // errCoverageUndecidable is returned when the allowlist and the request + // cannot be compared at all. That is a deployment-side condition, not + // something the client can correct by asking differently, and the + // comparison's own error names RBAC internals, so it is logged rather + // than rendered into error_description. + errCoverageUndecidable = xerrors.New("scope coverage against this app's allowed scopes could not be determined") ) // canonicalScopes rewrites each name to the spelling the api_key_scope enum @@ -104,7 +112,11 @@ func noScopeAllowlist(appScope sql.NullString) bool { // []string, and it is never empty alongside a nil error. Its names are // canonical api_key_scope spellings and carry no duplicates, so the value can // be stored as that enum without further rewriting. -func negotiateScope(requested []string, appScope sql.NullString) (string, error) { +// +// The whole app is taken rather than just its scope because a coverage failure +// is a deployment-side fault, and the log line that records it is only useful +// if it names the app that provoked it. +func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2ProviderApp, requested []string) (string, error) { // Only names in the external scope catalog (rbac.IsExternalScope) are // user-requestable. That is a curation, not a validity check: RBAC can // expand internal-only names such as debug_info:read just fine, and the @@ -122,7 +134,7 @@ func negotiateScope(requested []string, appScope sql.NullString) (string, error) // as the client spelled it rather than as the server stores it. granted := canonicalScopes(requested) - if noScopeAllowlist(appScope) { + if noScopeAllowlist(app.Scope) { if len(requested) == 0 { // Unrestricted, the same grant this app got before scope // enforcement existed, but stated explicitly: an empty string @@ -136,7 +148,7 @@ func negotiateScope(requested []string, appScope sql.NullString) (string, error) // anything. The allowlist was stored at registration time and may contain // a scope name since removed from the curated catalog, or never in it at // all. Filtering only ever narrows what is granted. - allowed := strings.Fields(appScope.String) + allowed := strings.Fields(app.Scope.String) filtered := make([]string, 0, len(allowed)) for _, a := range allowed { if rbac.IsExternalScope(rbac.ScopeName(a)) { @@ -178,10 +190,16 @@ func negotiateScope(requested []string, appScope sql.NullString) (string, error) covered, err := rbac.ScopesCover(allowedNames, rbac.ScopeName(s)) if err != nil { // Coverage could not be decided, so the request is refused rather - // than granted on an incomplete comparison. %w is last because - // xerrors repeats a wrapped message that is not, and this text is - // rendered into error_description for a person to read. - return "", xerrors.Errorf("%q (%v): %w", s, err, errScopeNotAllowed) + // than granted on an incomplete comparison. The comparison's own + // error names RBAC internals the client can do nothing with, so it + // goes to the log alongside the app that provoked it, and only the + // sentinel reaches error_description. + logger.Warn(ctx, "oauth2 scope coverage could not be determined", + slog.Error(err), + slog.F("app_id", app.ID.String()), + slog.F("app_scope", app.Scope.String), + slog.F("requested_scope", s)) + return "", xerrors.Errorf("%q: %w", s, errCoverageUndecidable) } if !covered { return "", xerrors.Errorf("%q: %w", s, errScopeNotAllowed) @@ -263,7 +281,7 @@ func extractAuthorizeParams(r *http.Request, callbackURL *url.URL) (authorizePar } // ShowAuthorizePage handles GET /oauth2/authorize requests to display the HTML authorization page. -func ShowAuthorizePage(accessURL *url.URL) http.HandlerFunc { +func ShowAuthorizePage(accessURL *url.URL, logger slog.Logger) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { app := httpmw.OAuth2ProviderApp(r) ua := httpmw.UserAuthorization(r.Context()) @@ -329,7 +347,7 @@ func ShowAuthorizePage(accessURL *url.URL) http.HandlerFunc { // renders at all, the POST side to persist the result. The two // negotiate the same query string, since the consent form posts back // to this URL. - if _, err := negotiateScope(params.scope, app.Scope); err != nil { + if _, err := negotiateScope(r.Context(), logger, app, params.scope); err != nil { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusBadRequest, HideStatus: false, @@ -386,7 +404,7 @@ func ShowAuthorizePage(accessURL *url.URL) http.HandlerFunc { // ProcessAuthorize handles POST /oauth2/authorize requests to process the user's authorization decision // and generate an authorization code. -func ProcessAuthorize(db database.Store) http.HandlerFunc { +func ProcessAuthorize(db database.Store, logger slog.Logger) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) @@ -423,7 +441,7 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc { return } - grantedScope, err := negotiateScope(params.scope, app.Scope) + grantedScope, err := negotiateScope(ctx, logger, app, params.scope) if err != nil { httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidScope, err.Error()) diff --git a/coderd/oauth2provider/authorize_internal_test.go b/coderd/oauth2provider/authorize_internal_test.go index 1b757719f32..52f321f8581 100644 --- a/coderd/oauth2provider/authorize_internal_test.go +++ b/coderd/oauth2provider/authorize_internal_test.go @@ -7,9 +7,11 @@ import ( "strings" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/rbac" ) @@ -272,7 +274,8 @@ func TestNegotiateScope(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - got, err := negotiateScope(test.requested, test.appScope) + app := database.OAuth2ProviderApp{ID: uuid.New(), Scope: test.appScope} + got, err := negotiateScope(t.Context(), slogtest.Make(t, nil), app, test.requested) if test.wantErr != nil { require.ErrorIs(t, err, test.wantErr) assert.Empty(t, got, "a rejected request must not return a persistable scope") From 2760db5789ad5cff95d6ef8bbc801d1875a9033e Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 21 Aug 2026 00:48:40 +0000 Subject: [PATCH 20/33] fix(coderd/oauth2provider): name the stored allowlist when none of it is grantable The rejection named the filter's input, rejoined from fields. For a whitespace-only allowlist that input is empty, so the app owner was shown "" as the value they had to change: the one configuration where the message is the only clue anything is set at all. It now names the stored value verbatim. --- coderd/oauth2provider/authorize.go | 8 ++-- .../oauth2provider/authorize_internal_test.go | 38 ++++++++++++------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index 8c61f8f7efe..fa279d2d3b9 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -163,9 +163,11 @@ func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2 // filter above handles, and it must not share the no-allowlist // branch's fallback. // - // Named with the pre-filter list, since that is what was registered - // and what the app owner has to change. - return "", xerrors.Errorf("%q: %w", strings.Join(allowed, " "), errNoGrantableScope) + // Named with the stored value verbatim, since that is what was + // registered and what the app owner has to change. Rejoining the + // filter's input instead would render a whitespace-only allowlist as + // "", naming nothing for the one configuration that most needs it. + return "", xerrors.Errorf("%q: %w", app.Scope.String, errNoGrantableScope) } // Canonicalized so both sides expand: rbac.ExpandScope knows `coder:all` // and not the `all` alias that IsExternalScope accepts. diff --git a/coderd/oauth2provider/authorize_internal_test.go b/coderd/oauth2provider/authorize_internal_test.go index 52f321f8581..2c37b7b396a 100644 --- a/coderd/oauth2provider/authorize_internal_test.go +++ b/coderd/oauth2provider/authorize_internal_test.go @@ -32,16 +32,18 @@ func TestNegotiateScope(t *testing.T) { noAllowlist := sql.NullString{} emptyAllowlist := sql.NullString{String: "", Valid: true} - // wantErr names the branch a rejection must come from. The three reasons - // are separately reachable and separately meaningful, so asserting only - // that some error occurred would let a refactor route one branch through - // another unnoticed. + // wantErr names the branch a rejection must come from. The reasons are + // separately reachable and separately meaningful, so asserting only that + // some error occurred would let a refactor route one branch through + // another unnoticed. wantErrText, where set, additionally pins what the + // person reading error_description is shown. tests := []struct { - name string - requested []string - appScope sql.NullString - want string - wantErr error + name string + requested []string + appScope sql.NullString + want string + wantErr error + wantErrText string }{ { name: "UnknownRequestedScopeRejected", @@ -208,10 +210,16 @@ func TestNegotiateScope(t *testing.T) { // A whitespace-only allowlist is a configured value that grants // nothing, not an unset one, so it rejects rather than falling // back to unrestricted. - name: "WhitespaceOnlyAllowlistRejected", - requested: nil, - appScope: sql.NullString{String: " ", Valid: true}, - wantErr: errNoGrantableScope, + // + // The rendered text is pinned because this is the one allowlist + // whose entries all vanish before the message is built: naming the + // filter's input rather than the stored value would show the app + // owner an empty string where their configuration should be. + name: "WhitespaceOnlyAllowlistRejected", + requested: nil, + appScope: sql.NullString{String: " ", Valid: true}, + wantErr: errNoGrantableScope, + wantErrText: `" "`, }, { // rbac.IsExternalScope accepts `all` as a backward-compatible @@ -286,6 +294,10 @@ func TestNegotiateScope(t *testing.T) { // errors.Is. assert.Equal(t, 1, strings.Count(err.Error(), test.wantErr.Error()), "the rejection reason must appear once, not doubled by the wrap") + if test.wantErrText != "" { + assert.Contains(t, err.Error(), test.wantErrText, + "the rejection must name the value the app owner has to change") + } return } require.NoError(t, err) From 5b2ca577787838a5278b32b0976929fc554bcccd Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 21 Aug 2026 00:51:29 +0000 Subject: [PATCH 21/33] refactor(coderd/oauth2provider): reword the oauth2 scope rejection reasons Two reasons said things the code does not do. "scope is not in this app's allowed scope list" described membership, but the check is permission coverage: a scope the allowlist never names is granted when a listed composite already confers it. A client reading the old text would go looking for its scope in a list it was never matched against. "re-register the app with supported scopes" prescribed the one remedy a DCR client has. An admin-created app is edited, not re-registered, and a DCR client can update itself in place through RFC 7592. The new text carries an apostrophe on the path the GET handler renders through an HTML template, so the helper that reads those responses now unescapes before matching. --- coderd/oauth2provider/authorize.go | 15 +++++++++------ coderd/oauth2provider/authorize_test.go | 8 ++++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index fa279d2d3b9..35b149a9b1b 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -42,12 +42,15 @@ var ( // errNoGrantableScope is returned when every entry of the app's allowlist // falls outside the catalog, leaving nothing the app can be granted. The // request is not at fault here and may have carried no scope at all, so - // the message names the registered list and the only remedy, which is - // re-registering the app. - errNoGrantableScope = xerrors.New("none of the scopes registered for this app are supported by this deployment; re-register the app with supported scopes") - // errScopeNotAllowed is returned for a catalog scope the app's allowlist - // does not cover. - errScopeNotAllowed = xerrors.New("scope is not in this app's allowed scope list") + // the message names the registered list and points at the remedy without + // prescribing a route to it: an admin edits the app, and a dynamically + // registered client updates itself through RFC 7592. + errNoGrantableScope = xerrors.New("none of the scopes registered for this app are supported by this deployment; change the app's registered scopes to supported ones") + // errScopeNotAllowed is returned for a catalog scope whose permissions the + // app's allowlist does not cover. Phrased as coverage rather than list + // membership, because a scope absent from the allowlist by name is still + // granted when a listed composite already confers it. + errScopeNotAllowed = xerrors.New("scope requests permissions beyond this app's allowed scopes") // errCoverageUndecidable is returned when the allowlist and the request // cannot be compared at all. That is a deployment-side condition, not // something the client can correct by asking differently, and the diff --git a/coderd/oauth2provider/authorize_test.go b/coderd/oauth2provider/authorize_test.go index dc9245d3fcf..84d5aa7a8d9 100644 --- a/coderd/oauth2provider/authorize_test.go +++ b/coderd/oauth2provider/authorize_test.go @@ -3,6 +3,7 @@ package oauth2provider_test import ( "context" "database/sql" + "html" htmltemplate "html/template" "io" "net/http" @@ -378,7 +379,10 @@ var ( // change; this helper is rewritten there. // // The body is returned because reading it consumes it, so a caller asserting -// anything further has to work from this copy. +// anything further has to work from this copy. It is returned unescaped: the +// GET side's HTML template renders an apostrophe as ', and the reasons +// contain apostrophes, so a caller matching on what the reason says would +// otherwise have to know which of the two handlers answered it. func requireInvalidScope(t *testing.T, resp *http.Response, wantReason string) string { t.Helper() @@ -386,7 +390,7 @@ func requireInvalidScope(t *testing.T, resp *http.Response, wantReason string) s require.Empty(t, resp.Header.Get("Location"), "a rejected request must not be redirected anywhere, least of all with a code") - body := readBody(t, resp) + body := html.UnescapeString(readBody(t, resp)) require.Contains(t, body, wantReason, "the rejection must come from the branch this case covers") return body From 90a6f8381dc228f76da6839f2d2bacd2717aae33 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 21 Aug 2026 00:53:36 +0000 Subject: [PATCH 22/33] docs: correct what the oauth2 authorize scope parameter promises The swagger annotation said a requested scope must be within the app's configured allowlist, which is wrong twice over. The allowlist is checked by permission coverage, not name membership, and it is not the only gate: every requested name must also be in this deployment's scope catalog, including for an app that has no allowlist at all. The omitted-scope default was likewise stated only for apps that have one. Two code comments went stale the same way. The branch table called the omitted-scope default the whole allowlist when it is the catalog-filtered one, and the comment over the persisted scope said the token minted from the code will carry it, which is the next phase's work, not this one's. --- coderd/apidoc/docs.go | 4 ++-- coderd/apidoc/swagger.json | 4 ++-- coderd/oauth2.go | 4 ++-- coderd/oauth2provider/authorize.go | 8 +++++--- docs/reference/api/enterprise.md | 28 ++++++++++++++-------------- 5 files changed, 25 insertions(+), 23 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d8dde1d5e50..8aa704a1e14 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14865,7 +14865,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted", + "description": "Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist", "name": "scope", "in": "query" } @@ -14921,7 +14921,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted", + "description": "Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist", "name": "scope", "in": "query" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0d8c64216b0..a720b52011f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13210,7 +13210,7 @@ }, { "type": "string", - "description": "Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted", + "description": "Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist", "name": "scope", "in": "query" } @@ -13261,7 +13261,7 @@ }, { "type": "string", - "description": "Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted", + "description": "Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist", "name": "scope", "in": "query" } diff --git a/coderd/oauth2.go b/coderd/oauth2.go index df7e281b736..fcdb66e3e86 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -120,7 +120,7 @@ func (api *API) deleteOAuth2ProviderAppSecret() http.HandlerFunc { // @Param state query string true "A random unguessable string" // @Param response_type query codersdk.OAuth2ProviderResponseType true "Response type" // @Param redirect_uri query string false "Redirect here after authorization" -// @Param scope query string false "Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted" +// @Param scope query string false "Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist" // @Success 200 "Returns HTML authorization page" // @Router /oauth2/authorize [get] func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc { @@ -135,7 +135,7 @@ func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc { // @Param state query string true "A random unguessable string" // @Param response_type query codersdk.OAuth2ProviderResponseType true "Response type" // @Param redirect_uri query string false "Redirect here after authorization" -// @Param scope query string false "Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted" +// @Param scope query string false "Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist" // @Success 302 "Returns redirect with authorization code" // @Router /oauth2/authorize [post] func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc { diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index 35b149a9b1b..cabbce1640d 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -102,7 +102,7 @@ func noScopeAllowlist(appScope sql.NullString) bool { // allowlist request result // absent absent ApiKeyScopeCoderAll, the pre-enforcement grant // absent present the request, which is narrower than unrestricted -// present absent the whole allowlist (RFC 6749 §3.3 default) +// present absent the allowlist, catalog-filtered (RFC 6749 §3.3 default) // present present the request, once shown to be within the allowlist // // An allowlist is absent when NULL or empty, which noScopeAllowlist treats as @@ -490,8 +490,10 @@ func ProcessAuthorize(db database.Store, logger slog.Logger) http.HandlerFunc { StateHash: hashOAuth2State(params.state), RedirectUri: sql.NullString{String: params.redirectURL.String(), Valid: params.redirectURIProvided}, // The negotiated scope, not the requested one: it has been - // checked against the scope catalog and the app's allowlist, - // and it is what the token minted from this code will carry. + // checked against the scope catalog and the app's allowlist. + // The exchange copies it onto the token row but does not yet + // put it on the API key it mints, so what is recorded here is + // what was agreed, not yet what is enforced. Scope: grantedScope, }) if err != nil { diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index faef045da3b..49d6d63a501 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -4782,13 +4782,13 @@ curl -X GET http://coder-server:8080/oauth2/authorize?client_id=string&state=str ### Parameters -| Name | In | Type | Required | Description | -|-----------------|-------|--------|----------|---------------------------------------------------------------------------------------------------------------------------------| -| `client_id` | query | string | true | Client ID | -| `state` | query | string | true | A random unguessable string | -| `response_type` | query | string | true | Response type | -| `redirect_uri` | query | string | false | Redirect here after authorization | -| `scope` | query | string | false | Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted | +| Name | In | Type | Required | Description | +|-----------------|-------|--------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client_id` | query | string | true | Client ID | +| `state` | query | string | true | A random unguessable string | +| `response_type` | query | string | true | Response type | +| `redirect_uri` | query | string | false | Redirect here after authorization | +| `scope` | query | string | false | Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist | #### Enumerated Values @@ -4818,13 +4818,13 @@ curl -X POST http://coder-server:8080/oauth2/authorize?client_id=string&state=st ### Parameters -| Name | In | Type | Required | Description | -|-----------------|-------|--------|----------|---------------------------------------------------------------------------------------------------------------------------------| -| `client_id` | query | string | true | Client ID | -| `state` | query | string | true | A random unguessable string | -| `response_type` | query | string | true | Response type | -| `redirect_uri` | query | string | false | Redirect here after authorization | -| `scope` | query | string | false | Space-separated scopes to request. Must be within the app's configured scope allowlist; defaults to that allowlist when omitted | +| Name | In | Type | Required | Description | +|-----------------|-------|--------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client_id` | query | string | true | Client ID | +| `state` | query | string | true | A random unguessable string | +| `response_type` | query | string | true | Response type | +| `redirect_uri` | query | string | false | Redirect here after authorization | +| `scope` | query | string | false | Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist | #### Enumerated Values From 77654a1142eae6d37a674c177408c11eb60b0862 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 21 Aug 2026 00:54:42 +0000 Subject: [PATCH 23/33] test(coderd/oauth2provider): fold the no-allowlist guarantee into one subtest NoAllowlistStaysUnrestricted and NullAndEmptyAllowlistBehaveIdentically sent the same request against the same NULL-allowlist app and asserted the same persisted value. The second already covers the first, so the guarantee moves into its comment rather than staying as a subtest that only restates it. --- coderd/oauth2provider/authorize_test.go | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/coderd/oauth2provider/authorize_test.go b/coderd/oauth2provider/authorize_test.go index 84d5aa7a8d9..e1aa406cb9d 100644 --- a/coderd/oauth2provider/authorize_test.go +++ b/coderd/oauth2provider/authorize_test.go @@ -168,21 +168,14 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, scopeInCatalog, persistedCodeScope(ctx, t, db, resp)) }) - // Apps with no configured allowlist keep today's unrestricted behavior. The persisted value is asserted literally, since '' is what the - // column's CHECK would reject. - t.Run("NoAllowlistStaysUnrestricted", func(t *testing.T) { - t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) - - app := seedApp(t, sql.NullString{}) - resp := authorizeRequest(ctx, t, client, http.MethodPost, app.ID.String(), "") - defer resp.Body.Close() - - require.Equal(t, string(database.ApiKeyScopeCoderAll), persistedCodeScope(ctx, t, db, resp)) - }) - // NULL (admin-created apps) and '' (DCR apps that sent no scope) are one // "no allowlist configured" state and must behave identically. + // + // This also carries the backward-compatibility guarantee: an app with no + // allowlist keeps the unrestricted grant it had before scope enforcement + // existed. The value is asserted literally rather than as "not empty", + // since '' is what the column's CHECK would reject and coder:all is what + // the pre-enforcement grant amounted to. t.Run("NullAndEmptyAllowlistBehaveIdentically", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) From 81bbfb0fbd2321acf5a326b004d3c44a5e5df8df Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sun, 23 Aug 2026 19:19:45 +0000 Subject: [PATCH 24/33] docs: trim OAuth2 scope negotiation comments The comments on negotiateScope, its sentinels, and the scope tests restated what the code and the case names already say. Keep the non-obvious parts: the branch table, the alias-versus-enum reason for canonicalization, the NULL/'' unification, the CHECK on the scope column, and the xerrors wrap ordering the doubled-text assertion guards. Drop RFC citations from test cases and shorten the swagger scope description. --- coderd/apidoc/docs.go | 4 +- coderd/apidoc/swagger.json | 4 +- coderd/oauth2.go | 4 +- coderd/oauth2_metadata_validation_test.go | 20 +-- coderd/oauth2provider/authorize.go | 154 +++++++----------- .../oauth2provider/authorize_internal_test.go | 143 +++++++--------- coderd/oauth2provider/authorize_test.go | 95 +++++------ coderd/oauth2provider/validation_test.go | 25 +-- docs/reference/api/enterprise.md | 28 ++-- 9 files changed, 191 insertions(+), 286 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ccf5ac57bf0..6b72f505ccc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14937,7 +14937,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist", + "description": "Space-separated scopes to request. Each must be supported by this deployment, and the app's allowlist, when it has one, must cover the permissions requested rather than name each scope. Defaults to that allowlist, or to coder:all for an app with no allowlist", "name": "scope", "in": "query" } @@ -14993,7 +14993,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist", + "description": "Space-separated scopes to request. Each must be supported by this deployment, and the app's allowlist, when it has one, must cover the permissions requested rather than name each scope. Defaults to that allowlist, or to coder:all for an app with no allowlist", "name": "scope", "in": "query" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 57b798c8736..ddf4fe33e59 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13272,7 +13272,7 @@ }, { "type": "string", - "description": "Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist", + "description": "Space-separated scopes to request. Each must be supported by this deployment, and the app's allowlist, when it has one, must cover the permissions requested rather than name each scope. Defaults to that allowlist, or to coder:all for an app with no allowlist", "name": "scope", "in": "query" } @@ -13323,7 +13323,7 @@ }, { "type": "string", - "description": "Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist", + "description": "Space-separated scopes to request. Each must be supported by this deployment, and the app's allowlist, when it has one, must cover the permissions requested rather than name each scope. Defaults to that allowlist, or to coder:all for an app with no allowlist", "name": "scope", "in": "query" } diff --git a/coderd/oauth2.go b/coderd/oauth2.go index fcdb66e3e86..fd0a2621a3c 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -120,7 +120,7 @@ func (api *API) deleteOAuth2ProviderAppSecret() http.HandlerFunc { // @Param state query string true "A random unguessable string" // @Param response_type query codersdk.OAuth2ProviderResponseType true "Response type" // @Param redirect_uri query string false "Redirect here after authorization" -// @Param scope query string false "Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist" +// @Param scope query string false "Space-separated scopes to request. Each must be supported by this deployment, and the app's allowlist, when it has one, must cover the permissions requested rather than name each scope. Defaults to that allowlist, or to coder:all for an app with no allowlist" // @Success 200 "Returns HTML authorization page" // @Router /oauth2/authorize [get] func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc { @@ -135,7 +135,7 @@ func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc { // @Param state query string true "A random unguessable string" // @Param response_type query codersdk.OAuth2ProviderResponseType true "Response type" // @Param redirect_uri query string false "Redirect here after authorization" -// @Param scope query string false "Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist" +// @Param scope query string false "Space-separated scopes to request. Each must be supported by this deployment, and the app's allowlist, when it has one, must cover the permissions requested rather than name each scope. Defaults to that allowlist, or to coder:all for an app with no allowlist" // @Success 302 "Returns redirect with authorization code" // @Router /oauth2/authorize [post] func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc { diff --git a/coderd/oauth2_metadata_validation_test.go b/coderd/oauth2_metadata_validation_test.go index 3bce27a8afd..580d544e59e 100644 --- a/coderd/oauth2_metadata_validation_test.go +++ b/coderd/oauth2_metadata_validation_test.go @@ -541,14 +541,12 @@ func TestOAuth2ClientNameValidation(t *testing.T) { } } -// TestOAuth2ClientScopeValidation tests scope parameter validation at -// registration time, which accepts any syntactically valid scope string. +// TestOAuth2ClientScopeValidation tests scope validation at registration time. // -// Registration performs no scope catalog validation, so these values are -// stored verbatim as the app's scope allowlist. Authorization is where the -// catalog is enforced: none of the names below is in rbac.IsExternalScope, so -// an app registered with one can no longer complete an authorization, whether -// it requests that scope or omits scope entirely. See +// Registration stores the scope string verbatim without checking it against +// the scope catalog, so every value below is accepted. Authorization enforces +// the catalog: an app whose allowlist names nothing grantable can no longer +// complete a flow, whether it requests a scope or omits one. See // TestOAuth2AuthorizeDCRScopeCompatibility in coderd/oauth2provider. func TestOAuth2ClientScopeValidation(t *testing.T) { t.Parallel() @@ -604,11 +602,9 @@ func TestOAuth2ClientScopeValidation(t *testing.T) { expectError: false, }, { - name: "InvalidAdmin", - scope: "admin", - // Registration accepts it; authorization rejects it with - // invalid_scope, since "admin" is not a grantable scope name. - expectError: false, + name: "InvalidAdmin", + scope: "admin", + expectError: false, // Rejected at authorization, not registration. }, { name: "ValidCustom", diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index cabbce1640d..26c51a1739f 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -27,47 +27,36 @@ import ( "github.com/coder/coder/v2/site" ) -// Rejection reasons from negotiateScope. They are sentinels rather than inline -// messages so a caller, and the tests, can tell which check failed without -// matching on message text. -// -// Each is wrapped with the offending value ahead of it, because xerrors only -// wraps without repeating the sentinel's own text when %w is the final verb. -// These messages are rendered into error_description, so a doubled one is read -// by a person. +// Rejection reasons from negotiateScope. These are rendered into +// error_description, and each is wrapped as `%q: %w` with the offending value +// ahead of it: xerrors only avoids repeating the sentinel's own text when %w +// is the final verb. var ( - // errUnknownScope is returned for a scope name outside the external scope + // errUnknownScope covers a requested name outside the external scope // catalog, whether unrecognized entirely or recognized but internal-only. errUnknownScope = xerrors.New("unknown or unsupported scope") - // errNoGrantableScope is returned when every entry of the app's allowlist - // falls outside the catalog, leaving nothing the app can be granted. The - // request is not at fault here and may have carried no scope at all, so - // the message names the registered list and points at the remedy without - // prescribing a route to it: an admin edits the app, and a dynamically - // registered client updates itself through RFC 7592. + // errNoGrantableScope covers an allowlist whose every entry falls outside + // the catalog. The remedy is left unprescribed: an admin edits the app, a + // dynamically registered client updates itself through RFC 7592. errNoGrantableScope = xerrors.New("none of the scopes registered for this app are supported by this deployment; change the app's registered scopes to supported ones") - // errScopeNotAllowed is returned for a catalog scope whose permissions the - // app's allowlist does not cover. Phrased as coverage rather than list - // membership, because a scope absent from the allowlist by name is still - // granted when a listed composite already confers it. + // errScopeNotAllowed is phrased as coverage rather than list membership, + // because a scope the allowlist does not name is still granted when a + // listed composite already confers it. errScopeNotAllowed = xerrors.New("scope requests permissions beyond this app's allowed scopes") - // errCoverageUndecidable is returned when the allowlist and the request - // cannot be compared at all. That is a deployment-side condition, not - // something the client can correct by asking differently, and the - // comparison's own error names RBAC internals, so it is logged rather - // than rendered into error_description. + // errCoverageUndecidable covers a comparison that failed outright. The + // underlying error names RBAC internals, so it is logged rather than + // rendered into error_description. errCoverageUndecidable = xerrors.New("scope coverage against this app's allowed scopes could not be determined") ) // canonicalScopes rewrites each name to the spelling the api_key_scope enum -// stores and drops repeats, preserving the order of first appearance. +// stores and drops repeats, preserving the order of first appearance. It +// neither validates nor filters: callers check rbac.IsExternalScope separately. // -// It neither validates nor filters: callers check rbac.IsExternalScope -// separately. Canonicalization is required because rbac.IsExternalScope -// accepts the aliases `all` and `application_connect`, which are not enum -// members, so persisting a validated name verbatim can write a value the -// column's vocabulary does not contain. Deduplicating here keeps the stored -// value set-valued, which is what a space-separated scope denotes. +// Canonicalization matters because rbac.IsExternalScope accepts the aliases +// `all` and `application_connect`, which are not enum members, so persisting a +// validated name verbatim can write a value the column's vocabulary does not +// contain. func canonicalScopes(names []string) []string { canonical := make([]string, 0, len(names)) for _, name := range names { @@ -77,12 +66,9 @@ func canonicalScopes(names []string) []string { } // noScopeAllowlist reports whether an app has no scope allowlist configured. -// NULL and "" are one state, and this is the only place the two are unified: -// admin-created apps store sql.NullString{} (apps.go), while DCR-registered -// apps store Valid: true carrying a possibly-empty req.Scope -// (registration.go). Once the allowlist decides what a token may do, reading -// it is an authorization decision, so the two encodings route through one -// predicate rather than each caller flattening via .String. +// NULL and "" are one state: admin-created apps store sql.NullString{} +// (apps.go), while DCR-registered apps store Valid: true carrying a +// possibly-empty req.Scope (registration.go). // // A whitespace-only allowlist is deliberately not this state. It is a // configured value that grants nothing, so it falls through to @@ -93,9 +79,9 @@ func noScopeAllowlist(appScope sql.NullString) bool { } // negotiateScope decides the scope the authorization code will carry. Every -// requested name must be in the external scope catalog (RFC 6749 §4.1.2.1 -// invalid_scope), and the request must be covered by the app's configured -// allowlist. +// requested name must be in the external scope catalog, and the request must +// be covered by the app's configured allowlist. A rejection is an RFC 6749 +// §4.1.2.1 invalid_scope. // // What each branch returns: // @@ -105,28 +91,18 @@ func noScopeAllowlist(appScope sql.NullString) bool { // present absent the allowlist, catalog-filtered (RFC 6749 §3.3 default) // present present the request, once shown to be within the allowlist // -// An allowlist is absent when NULL or empty, which noScopeAllowlist treats as -// one state. An allowlist whose every entry falls outside the catalog is -// rejected rather than read as absent, since falling back there would grant -// strictly more than the allowlist ever permitted. -// -// The return value is written directly to a NOT NULL column whose CHECK -// constraint also rejects the empty string, so it is a string rather than a -// []string, and it is never empty alongside a nil error. Its names are -// canonical api_key_scope spellings and carry no duplicates, so the value can -// be stored as that enum without further rewriting. +// An allowlist whose every entry falls outside the catalog is rejected rather +// than read as absent, since falling back there would grant strictly more than +// the allowlist ever permitted. // -// The whole app is taken rather than just its scope because a coverage failure -// is a deployment-side fault, and the log line that records it is only useful -// if it names the app that provoked it. +// The result is written directly to a NOT NULL column whose CHECK also rejects +// the empty string, so it is never empty alongside a nil error, and its names +// are canonical api_key_scope spellings carrying no duplicates. func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2ProviderApp, requested []string) (string, error) { - // Only names in the external scope catalog (rbac.IsExternalScope) are - // user-requestable. That is a curation, not a validity check: RBAC can - // expand internal-only names such as debug_info:read just fine, and the - // api_key_scope enum would store them, which is exactly why the catalog - // exists as a narrower list. Checking here keeps both an unrecognizable - // name and an internal-only one out of the granted scope, whether or not - // the app has an allowlist to check against. + // The catalog is a curation, not a validity check: RBAC can expand + // internal-only names such as debug_info:read, and the api_key_scope enum + // would store them. Only catalog names are client-requestable, whether or + // not the app has an allowlist to check them against. for _, s := range requested { if !rbac.IsExternalScope(rbac.ScopeName(s)) { return "", xerrors.Errorf("%q: %w", s, errUnknownScope) @@ -140,17 +116,16 @@ func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2 if noScopeAllowlist(app.Scope) { if len(requested) == 0 { // Unrestricted, the same grant this app got before scope - // enforcement existed, but stated explicitly: an empty string + // enforcement existed, stated explicitly because an empty string // would violate the column's CHECK. return string(database.ApiKeyScopeCoderAll), nil } return strings.Join(granted, " "), nil } - // Filter the allowlist through IsExternalScope before it is used for - // anything. The allowlist was stored at registration time and may contain - // a scope name since removed from the curated catalog, or never in it at - // all. Filtering only ever narrows what is granted. + // The allowlist was stored at registration time and may name a scope since + // removed from the catalog, or never in it. Filtering only ever narrows + // what is granted. allowed := strings.Fields(app.Scope.String) filtered := make([]string, 0, len(allowed)) for _, a := range allowed { @@ -159,17 +134,12 @@ func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2 } } if len(filtered) == 0 { - // The app has an allowlist, but no entry in it is grantable. - // Returning the unrestricted sentinel here would grant strictly more - // than the allowlist ever permitted, so reject instead. This is the - // all-entries-dropped counterpart to the single-stale-entry case the - // filter above handles, and it must not share the no-allowlist - // branch's fallback. + // Falling through to the no-allowlist branch would grant strictly more + // than this allowlist ever permitted. // - // Named with the stored value verbatim, since that is what was - // registered and what the app owner has to change. Rejoining the - // filter's input instead would render a whitespace-only allowlist as - // "", naming nothing for the one configuration that most needs it. + // The message names the stored value verbatim rather than rejoining + // the filter's input, which would render a whitespace-only allowlist + // as "" for the one configuration that most needs naming. return "", xerrors.Errorf("%q: %w", app.Scope.String, errNoGrantableScope) } // Canonicalized so both sides expand: rbac.ExpandScope knows `coder:all` @@ -181,12 +151,9 @@ func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2 } // The allowlist is a ceiling on authority, not a menu of spellings, so the - // check is permission coverage rather than name membership. An app allowed + // check is permission coverage rather than name membership: an app allowed // `coder:workspaces.access` can approve a client asking only for - // `workspace:read`, which the composite already grants; under name - // matching that client's only route to a token was to request the broader - // composite instead. Coverage runs against the filtered allowlist, not the - // raw one, so a dropped entry grants nothing. + // `workspace:read`, which that composite already grants. allowedNames := make([]rbac.ScopeName, 0, len(filtered)) for _, a := range filtered { allowedNames = append(allowedNames, rbac.ScopeName(a)) @@ -194,11 +161,9 @@ func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2 for _, s := range granted { covered, err := rbac.ScopesCover(allowedNames, rbac.ScopeName(s)) if err != nil { - // Coverage could not be decided, so the request is refused rather - // than granted on an incomplete comparison. The comparison's own - // error names RBAC internals the client can do nothing with, so it - // goes to the log alongside the app that provoked it, and only the - // sentinel reaches error_description. + // Refuse rather than grant on an incomplete comparison. The + // underlying error names RBAC internals the client can do nothing + // with, so it goes to the log alongside the app that provoked it. logger.Warn(ctx, "oauth2 scope coverage could not be determined", slog.Error(err), slog.F("app_id", app.ID.String()), @@ -346,12 +311,10 @@ func ShowAuthorizePage(accessURL *url.URL, logger slog.Logger) http.HandlerFunc return } - // Reject a scope the app can never be granted before the consent page - // renders, rather than after the user clicks Allow. Both handlers run - // the check for that reason: this one to decide whether the page - // renders at all, the POST side to persist the result. The two - // negotiate the same query string, since the consent form posts back - // to this URL. + // Negotiate here as well as on POST, so a request that cannot succeed + // fails before the consent page renders rather than after the user + // clicks Allow. The consent form posts back to this URL, so both + // handlers see the same query string and reach the same decision. if _, err := negotiateScope(r.Context(), logger, app, params.scope); err != nil { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusBadRequest, @@ -489,11 +452,10 @@ func ProcessAuthorize(db database.Store, logger slog.Logger) http.HandlerFunc { CodeChallengeMethod: sql.NullString{String: params.codeChallengeMethod, Valid: params.codeChallengeMethod != ""}, StateHash: hashOAuth2State(params.state), RedirectUri: sql.NullString{String: params.redirectURL.String(), Valid: params.redirectURIProvided}, - // The negotiated scope, not the requested one: it has been - // checked against the scope catalog and the app's allowlist. - // The exchange copies it onto the token row but does not yet - // put it on the API key it mints, so what is recorded here is - // what was agreed, not yet what is enforced. + // The negotiated scope, not the requested one. The exchange + // copies it onto the token row but does not yet put it on the + // API key it mints, so this records what was agreed, not yet + // what is enforced. Scope: grantedScope, }) if err != nil { diff --git a/coderd/oauth2provider/authorize_internal_test.go b/coderd/oauth2provider/authorize_internal_test.go index 2c37b7b396a..7133960d6b4 100644 --- a/coderd/oauth2provider/authorize_internal_test.go +++ b/coderd/oauth2provider/authorize_internal_test.go @@ -19,9 +19,8 @@ import ( func TestNegotiateScope(t *testing.T) { t.Parallel() - // Every scope name below is either in rbac.IsExternalScope's curated - // catalog or deliberately outside it; the test's meaning depends on which, - // so they are named rather than inlined. + // Whether a name is in rbac.IsExternalScope's curated catalog is the point + // of most cases below, so the two groups are named rather than inlined. const ( inCatalog = "coder:workspaces.access" alsoInCatalog = "coder:templates.build" @@ -32,11 +31,9 @@ func TestNegotiateScope(t *testing.T) { noAllowlist := sql.NullString{} emptyAllowlist := sql.NullString{String: "", Valid: true} - // wantErr names the branch a rejection must come from. The reasons are - // separately reachable and separately meaningful, so asserting only that - // some error occurred would let a refactor route one branch through - // another unnoticed. wantErrText, where set, additionally pins what the - // person reading error_description is shown. + // wantErr names the branch a rejection must come from, so a refactor + // cannot silently route one branch through another. wantErrText, where + // set, additionally pins the rendered text. tests := []struct { name string requested []string @@ -52,37 +49,32 @@ func TestNegotiateScope(t *testing.T) { wantErr: errUnknownScope, }, { - // The catalog check does not depend on the allowlist, so an - // unknown scope is rejected even where there is nothing to - // check it against. + // The catalog check does not depend on the allowlist. name: "UnknownRequestedScopeRejectedWithoutAllowlist", requested: []string{"not_a_real_scope"}, appScope: noAllowlist, wantErr: errUnknownScope, }, { - // A different rejection from the case above, and the one that - // matters more: debug_info:read is a real scope RBAC can expand - // and the api_key_scope enum can store. Only the catalog's - // curation keeps a client from negotiating an internal-only - // permission for itself. + // debug_info:read is a real scope RBAC can expand and the enum can + // store. Only the catalog's curation keeps a client from + // negotiating an internal-only permission for itself. name: "InternalOnlyScopeRejected", requested: []string{"debug_info:read"}, appScope: noAllowlist, wantErr: errUnknownScope, }, { - // The literal return value matters. "" is exactly what - // the column's CHECK rejects, so asserting only "no error" would - // let a DB-level 500 through. + // "" is exactly what the column's CHECK rejects, so asserting only + // "no error" would let a DB-level 500 through. name: "NoAllowlistOmittedRequestIsUnrestricted", requested: nil, appScope: noAllowlist, want: string(database.ApiKeyScopeCoderAll), }, { - // '' is the DCR-registered encoding of the same "no allowlist - // configured" state NULL expresses for admin-created apps. Both must reach the same branch. + // '' is the DCR-registered encoding of the unset state NULL + // expresses for admin-created apps. name: "EmptyAllowlistBehavesAsNoAllowlist", requested: nil, appScope: emptyAllowlist, @@ -101,7 +93,6 @@ func TestNegotiateScope(t *testing.T) { want: inCatalog, }, { - // RFC 6749 §3.3: an omitted scope defaults to the app's allowlist. name: "OmittedRequestDefaultsToAllowlist", requested: nil, appScope: sql.NullString{String: inCatalog + " " + alsoInCatalog, Valid: true}, @@ -121,100 +112,92 @@ func TestNegotiateScope(t *testing.T) { }, { // coder:workspaces.access grants template:read but not - // template:update, so the second name asks for authority the - // allowlist never carried. + // template:update. name: "PartiallyOutOfAllowlistRejected", requested: []string{inCatalog, "template:update"}, appScope: sql.NullString{String: inCatalog, Valid: true}, wantErr: errScopeNotAllowed, }, { - // The allowlist bounds authority, not spelling. A client asking - // for one permission the composite already grants gets a token - // narrower than the ceiling instead of being forced to request - // the whole composite to get any token at all. + // The allowlist bounds authority, not spelling: workspace:ssh is + // already granted by the composite, so the client gets a token + // narrower than the ceiling instead of having to ask for the + // whole composite. name: "LowLevelScopeCoveredByCompositeAllowlistAccepted", requested: []string{"workspace:ssh"}, appScope: sql.NullString{String: inCatalog, Valid: true}, want: "workspace:ssh", }, { - // Coverage is per requested name, so a request mixing a covered - // name with an uncovered one is refused whole rather than - // silently trimmed to the covered part. + // Coverage is per requested name, so a mixed request is refused + // whole rather than trimmed to its covered part. name: "PartiallyCoveredRequestRejectedWhole", requested: []string{"workspace:ssh", "workspace:delete"}, appScope: sql.NullString{String: inCatalog, Valid: true}, wantErr: errScopeNotAllowed, }, { - // The wildcard action is wider than the composite that covers - // its read half, so it is not covered by it. + // The wildcard action is wider than the composite covering its + // read half. name: "WildcardActionNotCoveredByCompositeAllowlist", requested: []string{"workspace:*"}, appScope: sql.NullString{String: inCatalog, Valid: true}, wantErr: errScopeNotAllowed, }, { - // coder:all expands to the wildcard resource and action, so it - // is a ceiling over every requestable name. + // coder:all expands to the wildcard resource and action. name: "AllAllowlistCoversAnyScope", requested: []string{"user_secret:delete"}, appScope: sql.NullString{String: string(database.ApiKeyScopeCoderAll), Valid: true}, want: "user_secret:delete", }, { - // Coverage reads the allowlist as one ceiling rather than - // checking each entry alone, so a request may draw on more than - // one entry at once. + // The allowlist is one combined ceiling, not a set of independent + // entries, so a request may draw on more than one at once. name: "CoverageSpansMultipleAllowlistEntries", requested: []string{"file:create", "workspace:ssh"}, appScope: sql.NullString{String: inCatalog + " " + alsoInCatalog, Valid: true}, want: "file:create workspace:ssh", }, { - // Catalog drift. The stale entry is dropped by the filter, and - // the surviving entry is still granted. + // Catalog drift: the stale entry is dropped, the surviving one is + // still granted. name: "StaleAllowlistEntryDroppedNotGranted", requested: nil, appScope: sql.NullString{String: inCatalog + " " + notInCatalog, Valid: true}, want: inCatalog, }, { - // A dropped entry cannot be reached by requesting it explicitly - // either. The catalog check on the request rejects it before the - // allowlist is consulted at all, which is why the reason here is - // errUnknownScope and not errScopeNotAllowed. + // The request's catalog check runs before the allowlist is + // consulted, so the reason is errUnknownScope rather than + // errScopeNotAllowed. name: "StaleAllowlistEntryNotRequestableExplicitly", requested: []string{notInCatalog}, appScope: sql.NullString{String: inCatalog + " " + notInCatalog, Valid: true}, wantErr: errUnknownScope, }, { - // The all-entries-dropped counterpart to the case above. Falling back to the unrestricted sentinel here would - // grant strictly more than this allowlist ever permitted. + // Falling back to the unrestricted sentinel here would grant + // strictly more than this allowlist ever permitted. name: "AllowlistFilteringToEmptyRejected", requested: nil, appScope: sql.NullString{String: "openid profile email", Valid: true}, wantErr: errNoGrantableScope, }, { - // The accepted compatibility break in its most direct form: a - // DCR client requesting exactly what it registered. + // The accepted compatibility break in its most direct form: a DCR + // client requesting exactly what it registered. name: "NonCatalogScopeRequestedAsRegistered", requested: []string{neverInCatalog}, appScope: sql.NullString{String: neverInCatalog, Valid: true}, wantErr: errUnknownScope, }, { - // A whitespace-only allowlist is a configured value that grants - // nothing, not an unset one, so it rejects rather than falling - // back to unrestricted. - // - // The rendered text is pinned because this is the one allowlist - // whose entries all vanish before the message is built: naming the - // filter's input rather than the stored value would show the app - // owner an empty string where their configuration should be. + // A whitespace-only allowlist is configured but grants nothing, so + // it rejects rather than falling back to unrestricted. The text is + // pinned because this is the one allowlist whose entries all + // vanish before the message is built, so naming the filter's input + // would show the owner an empty string. name: "WhitespaceOnlyAllowlistRejected", requested: nil, appScope: sql.NullString{String: " ", Valid: true}, @@ -223,9 +206,7 @@ func TestNegotiateScope(t *testing.T) { }, { // rbac.IsExternalScope accepts `all` as a backward-compatible - // alias, but the api_key_scope enum has no such member, so - // persisting the requested spelling verbatim would store a value - // outside the column's vocabulary. + // alias, but the api_key_scope enum has no such member. name: "LegacyAllAliasCanonicalized", requested: []string{"all"}, appScope: noAllowlist, @@ -239,8 +220,7 @@ func TestNegotiateScope(t *testing.T) { }, { // The allowlist is canonicalized on the same terms, so the two - // spellings of one scope match across the subset check rather - // than reading as different scopes. + // spellings match instead of reading as different scopes. name: "LegacyAliasInAllowlistCoversCanonicalRequest", requested: []string{"coder:all"}, appScope: sql.NullString{String: "all", Valid: true}, @@ -253,24 +233,22 @@ func TestNegotiateScope(t *testing.T) { want: "coder:all", }, { - // A space-separated scope denotes a set, so a repeated request - // stores one entry rather than two. + // A space-separated scope denotes a set. name: "DuplicateRequestedScopesDeduplicated", requested: []string{inCatalog, inCatalog}, appScope: noAllowlist, want: inCatalog, }, { - // The same holds for the RFC 6749 §3.3 default, which is built - // from the allowlist rather than from the request. + // The same holds for the default, which is built from the + // allowlist rather than from the request. name: "DuplicateAllowlistEntriesDeduplicated", requested: nil, appScope: sql.NullString{String: inCatalog + " " + inCatalog, Valid: true}, want: inCatalog, }, { - // Two spellings of one scope in the allowlist collapse to one - // entry, so the default does not name the same grant twice. + // Two spellings of one scope collapse to a single entry. name: "AliasAndCanonicalAllowlistEntriesCollapse", requested: nil, appScope: sql.NullString{String: "all coder:all", Valid: true}, @@ -287,11 +265,9 @@ func TestNegotiateScope(t *testing.T) { if test.wantErr != nil { require.ErrorIs(t, err, test.wantErr) assert.Empty(t, got, "a rejected request must not return a persistable scope") - // This message is rendered into error_description and onto - // the authorize error page, so it is read by a person. // xerrors repeats the wrapped text unless %w is the final - // verb, which is easy to reintroduce and invisible to - // errors.Is. + // verb, which errors.Is cannot catch and a person reading + // error_description sees. assert.Equal(t, 1, strings.Count(err.Error(), test.wantErr.Error()), "the rejection reason must appear once, not doubled by the wrap") if test.wantErrText != "" { @@ -302,8 +278,7 @@ func TestNegotiateScope(t *testing.T) { } require.NoError(t, err) assert.Equal(t, test.want, got) - // The return value goes straight to a NOT NULL column carrying - // CHECK (scope <> ''), so an empty success is never legal. + // The column is NOT NULL with CHECK (scope <> ''). assert.NotEmpty(t, got, "a successful negotiation must never return an empty scope") requirePersistableScope(t, got) }) @@ -311,11 +286,9 @@ func TestNegotiateScope(t *testing.T) { } // requirePersistableScope asserts that every name in a negotiated scope can -// survive the trip the value is about to take: stored as api_key_scope on the -// authorization code, carried to the token, and expanded by RBAC when the key -// minted from it is authorized. A name that passes the external scope catalog -// is not automatically one that clears all three, which is why this is -// asserted on the result rather than assumed from the input. +// survive the trip ahead of it: stored as api_key_scope on the authorization +// code, carried to the token, and expanded by RBAC when the key minted from it +// is authorized. Passing the external scope catalog does not imply all three. func requirePersistableScope(t *testing.T, scope string) { t.Helper() @@ -328,11 +301,9 @@ func requirePersistableScope(t *testing.T, scope string) { } } -// Rejection reasons handed to the package's black-box tests, which sit in -// oauth2provider_test and so cannot reach the sentinels themselves. They -// assert on what reaches the client, and the sentinel text is what reaches -// it, so binding here beats re-typing the strings over there: a rewording -// then moves both together instead of silently unpinning the branch mapping. +// Rejection reasons handed to the package's black-box tests, which cannot +// reach the sentinels themselves. Binding here rather than re-typing the +// strings over there keeps a rewording from silently unpinning them. var ( ReasonUnknownScope = errUnknownScope.Error() ReasonNoGrantableScope = errNoGrantableScope.Error() @@ -342,9 +313,9 @@ var ( func TestNoScopeAllowlist(t *testing.T) { t.Parallel() - // NULL and '' are one state. Both are produced in the tree today: - // sql.NullString{} by admin-created apps, Valid-with-empty-string by DCR - // registration that sent no scope. + // Both encodings are produced in the tree today: sql.NullString{} by + // admin-created apps, Valid-with-empty-string by DCR registration that + // sent no scope. assert.True(t, noScopeAllowlist(sql.NullString{})) assert.True(t, noScopeAllowlist(sql.NullString{String: "", Valid: true})) assert.False(t, noScopeAllowlist(sql.NullString{String: "coder:workspaces.access", Valid: true})) diff --git a/coderd/oauth2provider/authorize_test.go b/coderd/oauth2provider/authorize_test.go index e1aa406cb9d..d05299b99f3 100644 --- a/coderd/oauth2provider/authorize_test.go +++ b/coderd/oauth2provider/authorize_test.go @@ -49,20 +49,18 @@ func TestOAuthConsentFormIncludesCSRFToken(t *testing.T) { } // Scope names used by the negotiation tests. Whether a name is in -// rbac.IsExternalScope's curated catalog is the point of each case, so the two -// groups are named rather than inlined. +// rbac.IsExternalScope's curated catalog is the point of each case. const ( scopeInCatalog = "coder:workspaces.access" scopeAlsoInCatalog = "coder:templates.build" scopeOutOfCatalog = "some_removed_scope" - // In the catalog, and outside the authority scopeInCatalog carries: that - // composite grants template:read but never template:update. + // In the catalog, but outside the authority scopeInCatalog carries: that + // composite grants template:read, never template:update. scopeOutOfAllowlist = "template:update" ) // The callback every app in these tests registers, and the state every request -// sends. A rejection redirects to the first carrying the second, so both are -// named rather than inlined. +// sends. const ( appCallbackURL = "https://example.com/callback" authorizeState = "test-authorize-state" @@ -79,7 +77,7 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) // Each sub-test gets its own app: only one code exists per app/user pair at - // a time, and the allowlist is the variable under test. + // a time. seedApp := func(t *testing.T, appScope sql.NullString) database.OAuth2ProviderApp { t.Helper() return dbgen.OAuth2ProviderApp(t, db, database.OAuth2ProviderApp{ @@ -101,8 +99,7 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { }) // The allowlist bounds authority rather than spelling, so a name it never - // lists is still granted when the permissions it expands to are ones the - // allowlist already carries. + // lists is still granted when the permissions behind it are covered. t.Run("ScopeCoveredByAllowlistGranted", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -114,9 +111,8 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, "workspace:ssh", persistedCodeScope(ctx, t, db, resp)) }) - // The catalog half of the same guarantee: a scope name the enforcement - // layer cannot evaluate is rejected on its own terms, not because of the - // allowlist. + // The catalog half of the same guarantee: a name the enforcement layer + // cannot evaluate is rejected on its own terms, not by the allowlist. t.Run("UnknownScopeRejected", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -128,7 +124,6 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { requireInvalidScope(t, resp, reasonUnknownScope) }) - // Omitting scope grants the app's full allowlist (RFC 6749 §3.3). t.Run("OmittedScopeDefaultsToAllowlist", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -143,8 +138,7 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { // rbac.IsExternalScope accepts `all` as a backward-compatible alias, but // the api_key_scope enum has only `coder:all`. Asserted against the stored - // row rather than the negotiation's return value, because the column's - // vocabulary is what the claim is about. + // row, since the column's vocabulary is what the claim is about. t.Run("LegacyAliasPersistedCanonically", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -156,7 +150,7 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, string(database.ApiKeyScopeCoderAll), persistedCodeScope(ctx, t, db, resp)) }) - // A repeated scope denotes one grant, so it is stored once. + // A space-separated scope denotes a set. t.Run("DuplicateRequestedScopePersistedOnce", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -169,13 +163,9 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { }) // NULL (admin-created apps) and '' (DCR apps that sent no scope) are one - // "no allowlist configured" state and must behave identically. - // - // This also carries the backward-compatibility guarantee: an app with no - // allowlist keeps the unrestricted grant it had before scope enforcement - // existed. The value is asserted literally rather than as "not empty", - // since '' is what the column's CHECK would reject and coder:all is what - // the pre-enforcement grant amounted to. + // "no allowlist configured" state. This also carries the backward + // compatibility guarantee: such an app keeps the unrestricted grant it had + // before scope enforcement existed. t.Run("NullAndEmptyAllowlistBehaveIdentically", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -194,8 +184,9 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, nullScope, emptyScope) }) - // An allowlist entry no longer in the catalog is dropped, not granted. Paired with AllowlistFilteringToEmptyRejected below, which is the - // same filter with no survivors. + // An allowlist entry no longer in the catalog is dropped, not granted. + // AllowlistFilteringToEmptyRejected below is the same filter with no + // survivors. t.Run("StaleAllowlistEntryDropped", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -207,8 +198,7 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, scopeInCatalog, persistedCodeScope(ctx, t, db, resp)) }) - // An allowlist whose every entry is dropped rejects rather than falling - // back to unrestricted, which would grant strictly more than the + // Falling back to unrestricted here would grant strictly more than the // allowlist ever permitted. t.Run("AllowlistFilteringToEmptyRejected", func(t *testing.T) { t.Parallel() @@ -223,10 +213,10 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { } // TestOAuth2AuthorizeDCRScopeCompatibility pins an accepted compatibility -// break: dynamic client registration performs no catalog validation, so an -// app can register an allowlist this server cannot grant from. Both -// directions fail, and both fail loudly with invalid_scope rather than -// silently granting a scope dbauthz has no way to evaluate. +// break: dynamic client registration performs no catalog validation, so an app +// can register an allowlist this server cannot grant from. Requesting those +// scopes and omitting scope entirely both fail loudly with invalid_scope, +// rather than granting one dbauthz has no way to evaluate. func TestOAuth2AuthorizeDCRScopeCompatibility(t *testing.T) { t.Parallel() @@ -262,10 +252,9 @@ func TestOAuth2AuthorizeDCRScopeCompatibility(t *testing.T) { requireInvalidScope(t, resp, reasonNoGrantableScope) }) - // The break is only recoverable by whoever registered the app, so the - // description has to name the scopes they registered: the request that - // triggered this carried none, and the registered list is what they have - // to change. + // Only whoever registered the app can recover from the break, and the + // request that triggers it carries no scope of its own, so the description + // has to name the registered list. t.Run("RejectionNamesTheRegisteredScopes", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -279,9 +268,9 @@ func TestOAuth2AuthorizeDCRScopeCompatibility(t *testing.T) { }) } -// authorizeQuery builds a well-formed /oauth2/authorize query. Callers that -// need to vary a parameter the happy path does not, such as redirect_uri, -// mutate the result and pass it to sendAuthorizeRequest. +// authorizeQuery builds a well-formed /oauth2/authorize query. Callers needing +// to vary a parameter the happy path does not, such as redirect_uri, mutate +// the result and pass it to sendAuthorizeRequest. func authorizeQuery(t *testing.T, clientID, scope string) url.Values { t.Helper() @@ -328,9 +317,8 @@ func sendAuthorizeRequest(ctx context.Context, t *testing.T, client *codersdk.Cl return resp } -// persistedCodeScope follows a successful authorization to the code it issued -// and returns the scope recorded on that row, which is what the token exchange -// will later read. +// persistedCodeScope returns the scope recorded on the code a successful +// authorization issued, which is what the token exchange later reads. func persistedCodeScope(ctx context.Context, t *testing.T, db database.Store, resp *http.Response) string { t.Helper() @@ -351,9 +339,8 @@ func persistedCodeScope(ctx context.Context, t *testing.T, db database.Store, re // The rejection reasons from authorize.go, each unique to one branch. The // transport carries only the rendered description, so these pin over the wire -// what errors.Is pins in the package's own tests. They are bound to the -// sentinels rather than re-typed as substrings, so rewording one cannot leave -// a case asserting on text no branch produces any more. +// what errors.Is pins in the package's own tests. Binding to the sentinels +// rather than re-typing them keeps a rewording from unpinning a case. var ( reasonUnknownScope = oauth2provider.ReasonUnknownScope reasonNoGrantableScope = oauth2provider.ReasonNoGrantableScope @@ -363,19 +350,15 @@ var ( // requireInvalidScope asserts that a rejected request is refused rather than // issued a code, and that the refusal names the branch the caller expects. // -// Each handler answers in the form it already uses for its own errors: the GET -// side renders a static error page, since a person is looking at a browser, -// and the POST side writes an OAuth2 error body. Both carry the description in -// the response and neither redirects, so the assertion is on the status and -// the body rather than on a Location. Delivering these to the client's own -// callback, which is what RFC 6749 §4.1.2.1 actually calls for, is a separate -// change; this helper is rewritten there. +// Each handler answers in the form it already uses for its own errors: a +// static error page on GET, an OAuth2 error body on POST. Both carry the +// description in the response and neither redirects, so the assertion is on +// status and body rather than on a Location. Delivering these to the client's +// own callback instead is a separate change, which rewrites this helper. // -// The body is returned because reading it consumes it, so a caller asserting -// anything further has to work from this copy. It is returned unescaped: the -// GET side's HTML template renders an apostrophe as ', and the reasons -// contain apostrophes, so a caller matching on what the reason says would -// otherwise have to know which of the two handlers answered it. +// The body is returned because reading it consumes it. It is unescaped so that +// a caller matching on a reason, which contains an apostrophe the GET side's +// template renders as ', need not know which handler answered. func requireInvalidScope(t *testing.T, resp *http.Response, wantReason string) string { t.Helper() diff --git a/coderd/oauth2provider/validation_test.go b/coderd/oauth2provider/validation_test.go index d7164eadec6..de1ff3a0405 100644 --- a/coderd/oauth2provider/validation_test.go +++ b/coderd/oauth2provider/validation_test.go @@ -541,18 +541,13 @@ func TestOAuth2ClientNameValidation(t *testing.T) { } } -// TestOAuth2ClientScopeValidation tests scope parameter validation at -// registration time, which accepts any syntactically valid scope string. +// TestOAuth2ClientScopeValidation tests scope validation at registration time. // -// Registration performs no scope catalog validation, so the values below are -// stored verbatim as the app's scope allowlist. Authorization is where the -// catalog is enforced: a name outside rbac.IsExternalScope cannot be granted, -// so an app registered with only such names can no longer complete an -// authorization in either direction. Requesting one is rejected with -// invalid_scope, and omitting scope entirely is rejected too, because the -// allowlist filters to nothing. TestOAuth2AuthorizeDCRScopeCompatibility -// covers both. Every non-empty scope below is in that position: none of read, -// write, openid, profile, email, admin, or custom:scope is in the catalog. +// Registration stores the scope string verbatim without checking it against +// the scope catalog, so every value below is accepted. Authorization enforces +// the catalog: an app whose allowlist names nothing grantable can no longer +// complete a flow, whether it requests a scope or omits one. See +// TestOAuth2AuthorizeDCRScopeCompatibility. func TestOAuth2ClientScopeValidation(t *testing.T) { t.Parallel() @@ -607,11 +602,9 @@ func TestOAuth2ClientScopeValidation(t *testing.T) { expectError: false, }, { - name: "InvalidAdmin", - scope: "admin", - // Registration accepts it; authorization rejects it with - // invalid_scope, since "admin" is not a grantable scope name. - expectError: false, + name: "InvalidAdmin", + scope: "admin", + expectError: false, // Rejected at authorization, not registration. }, { name: "ValidCustom", diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index f069099357b..1c20131a693 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -4877,13 +4877,13 @@ curl -X GET http://coder-server:8080/oauth2/authorize?client_id=string&state=str ### Parameters -| Name | In | Type | Required | Description | -|-----------------|-------|--------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `client_id` | query | string | true | Client ID | -| `state` | query | string | true | A random unguessable string | -| `response_type` | query | string | true | Response type | -| `redirect_uri` | query | string | false | Redirect here after authorization | -| `scope` | query | string | false | Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist | +| Name | In | Type | Required | Description | +|-----------------|-------|--------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client_id` | query | string | true | Client ID | +| `state` | query | string | true | A random unguessable string | +| `response_type` | query | string | true | Response type | +| `redirect_uri` | query | string | false | Redirect here after authorization | +| `scope` | query | string | false | Space-separated scopes to request. Each must be supported by this deployment, and the app's allowlist, when it has one, must cover the permissions requested rather than name each scope. Defaults to that allowlist, or to coder:all for an app with no allowlist | #### Enumerated Values @@ -4913,13 +4913,13 @@ curl -X POST http://coder-server:8080/oauth2/authorize?client_id=string&state=st ### Parameters -| Name | In | Type | Required | Description | -|-----------------|-------|--------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `client_id` | query | string | true | Client ID | -| `state` | query | string | true | A random unguessable string | -| `response_type` | query | string | true | Response type | -| `redirect_uri` | query | string | false | Redirect here after authorization | -| `scope` | query | string | false | Space-separated scopes to request. Each must be a scope this deployment supports, and the app's scope allowlist, when it has one, must cover the permissions requested rather than list each name. When omitted, defaults to that allowlist, or to coder:all for an app with no allowlist | +| Name | In | Type | Required | Description | +|-----------------|-------|--------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client_id` | query | string | true | Client ID | +| `state` | query | string | true | A random unguessable string | +| `response_type` | query | string | true | Response type | +| `redirect_uri` | query | string | false | Redirect here after authorization | +| `scope` | query | string | false | Space-separated scopes to request. Each must be supported by this deployment, and the app's allowlist, when it has one, must cover the permissions requested rather than name each scope. Defaults to that allowlist, or to coder:all for an app with no allowlist | #### Enumerated Values From e63ff3aacb8a219c979c12b37e42b9a7b3d82bc0 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 25 Aug 2026 02:43:01 +0000 Subject: [PATCH 25/33] docs(coderd): trim the scope test comments --- coderd/oauth2_metadata_validation_test.go | 7 +- .../oauth2provider/authorize_internal_test.go | 93 +++++++------------ coderd/oauth2provider/authorize_test.go | 87 +++++++---------- coderd/oauth2provider/validation_test.go | 7 +- 4 files changed, 74 insertions(+), 120 deletions(-) diff --git a/coderd/oauth2_metadata_validation_test.go b/coderd/oauth2_metadata_validation_test.go index 580d544e59e..3173fb6eeac 100644 --- a/coderd/oauth2_metadata_validation_test.go +++ b/coderd/oauth2_metadata_validation_test.go @@ -542,11 +542,8 @@ func TestOAuth2ClientNameValidation(t *testing.T) { } // TestOAuth2ClientScopeValidation tests scope validation at registration time. -// -// Registration stores the scope string verbatim without checking it against -// the scope catalog, so every value below is accepted. Authorization enforces -// the catalog: an app whose allowlist names nothing grantable can no longer -// complete a flow, whether it requests a scope or omits one. See +// Registration stores the scope verbatim without a catalog check, so every +// value below is accepted. Authorization enforces the catalog: see // TestOAuth2AuthorizeDCRScopeCompatibility in coderd/oauth2provider. func TestOAuth2ClientScopeValidation(t *testing.T) { t.Parallel() diff --git a/coderd/oauth2provider/authorize_internal_test.go b/coderd/oauth2provider/authorize_internal_test.go index 7133960d6b4..74c18ad1235 100644 --- a/coderd/oauth2provider/authorize_internal_test.go +++ b/coderd/oauth2provider/authorize_internal_test.go @@ -19,8 +19,7 @@ import ( func TestNegotiateScope(t *testing.T) { t.Parallel() - // Whether a name is in rbac.IsExternalScope's curated catalog is the point - // of most cases below, so the two groups are named rather than inlined. + // Membership in rbac.IsExternalScope's catalog is the point of most cases. const ( inCatalog = "coder:workspaces.access" alsoInCatalog = "coder:templates.build" @@ -31,9 +30,8 @@ func TestNegotiateScope(t *testing.T) { noAllowlist := sql.NullString{} emptyAllowlist := sql.NullString{String: "", Valid: true} - // wantErr names the branch a rejection must come from, so a refactor - // cannot silently route one branch through another. wantErrText, where - // set, additionally pins the rendered text. + // wantErr names the branch a rejection must come from; wantErrText pins + // the rendered text. tests := []struct { name string requested []string @@ -56,25 +54,21 @@ func TestNegotiateScope(t *testing.T) { wantErr: errUnknownScope, }, { - // debug_info:read is a real scope RBAC can expand and the enum can - // store. Only the catalog's curation keeps a client from - // negotiating an internal-only permission for itself. + // RBAC can expand debug_info:read; only the catalog keeps it + // out of a client's reach. name: "InternalOnlyScopeRejected", requested: []string{"debug_info:read"}, appScope: noAllowlist, wantErr: errUnknownScope, }, { - // "" is exactly what the column's CHECK rejects, so asserting only - // "no error" would let a DB-level 500 through. name: "NoAllowlistOmittedRequestIsUnrestricted", requested: nil, appScope: noAllowlist, want: string(database.ApiKeyScopeCoderAll), }, { - // '' is the DCR-registered encoding of the unset state NULL - // expresses for admin-created apps. + // '' is how DCR encodes the unset state NULL expresses. name: "EmptyAllowlistBehavesAsNoAllowlist", requested: nil, appScope: emptyAllowlist, @@ -111,34 +105,29 @@ func TestNegotiateScope(t *testing.T) { want: alsoInCatalog, }, { - // coder:workspaces.access grants template:read but not - // template:update. + // coder:workspaces.access grants template:read, not template:update. name: "PartiallyOutOfAllowlistRejected", requested: []string{inCatalog, "template:update"}, appScope: sql.NullString{String: inCatalog, Valid: true}, wantErr: errScopeNotAllowed, }, { - // The allowlist bounds authority, not spelling: workspace:ssh is - // already granted by the composite, so the client gets a token - // narrower than the ceiling instead of having to ask for the - // whole composite. + // The allowlist bounds authority, not spelling: the composite + // already grants workspace:ssh. name: "LowLevelScopeCoveredByCompositeAllowlistAccepted", requested: []string{"workspace:ssh"}, appScope: sql.NullString{String: inCatalog, Valid: true}, want: "workspace:ssh", }, { - // Coverage is per requested name, so a mixed request is refused - // whole rather than trimmed to its covered part. + // Coverage is per name, so a mixed request is refused whole. name: "PartiallyCoveredRequestRejectedWhole", requested: []string{"workspace:ssh", "workspace:delete"}, appScope: sql.NullString{String: inCatalog, Valid: true}, wantErr: errScopeNotAllowed, }, { - // The wildcard action is wider than the composite covering its - // read half. + // The wildcard action is wider than the composite's read half. name: "WildcardActionNotCoveredByCompositeAllowlist", requested: []string{"workspace:*"}, appScope: sql.NullString{String: inCatalog, Valid: true}, @@ -152,52 +141,47 @@ func TestNegotiateScope(t *testing.T) { want: "user_secret:delete", }, { - // The allowlist is one combined ceiling, not a set of independent - // entries, so a request may draw on more than one at once. + // The allowlist is one combined ceiling, so a request may draw + // on several entries at once. name: "CoverageSpansMultipleAllowlistEntries", requested: []string{"file:create", "workspace:ssh"}, appScope: sql.NullString{String: inCatalog + " " + alsoInCatalog, Valid: true}, want: "file:create workspace:ssh", }, { - // Catalog drift: the stale entry is dropped, the surviving one is - // still granted. + // Catalog drift: the stale entry is dropped, the survivor granted. name: "StaleAllowlistEntryDroppedNotGranted", requested: nil, appScope: sql.NullString{String: inCatalog + " " + notInCatalog, Valid: true}, want: inCatalog, }, { - // The request's catalog check runs before the allowlist is - // consulted, so the reason is errUnknownScope rather than - // errScopeNotAllowed. + // The catalog check runs before the allowlist, so the reason is + // errUnknownScope. name: "StaleAllowlistEntryNotRequestableExplicitly", requested: []string{notInCatalog}, appScope: sql.NullString{String: inCatalog + " " + notInCatalog, Valid: true}, wantErr: errUnknownScope, }, { - // Falling back to the unrestricted sentinel here would grant - // strictly more than this allowlist ever permitted. + // Falling back to unrestricted would grant more than the + // allowlist ever permitted. name: "AllowlistFilteringToEmptyRejected", requested: nil, appScope: sql.NullString{String: "openid profile email", Valid: true}, wantErr: errNoGrantableScope, }, { - // The accepted compatibility break in its most direct form: a DCR - // client requesting exactly what it registered. + // The accepted compatibility break: a DCR client requesting + // exactly what it registered. name: "NonCatalogScopeRequestedAsRegistered", requested: []string{neverInCatalog}, appScope: sql.NullString{String: neverInCatalog, Valid: true}, wantErr: errUnknownScope, }, { - // A whitespace-only allowlist is configured but grants nothing, so - // it rejects rather than falling back to unrestricted. The text is - // pinned because this is the one allowlist whose entries all - // vanish before the message is built, so naming the filter's input - // would show the owner an empty string. + // Configured but grants nothing. The text is pinned because every + // entry vanishes before the message is built. name: "WhitespaceOnlyAllowlistRejected", requested: nil, appScope: sql.NullString{String: " ", Valid: true}, @@ -205,8 +189,8 @@ func TestNegotiateScope(t *testing.T) { wantErrText: `" "`, }, { - // rbac.IsExternalScope accepts `all` as a backward-compatible - // alias, but the api_key_scope enum has no such member. + // rbac.IsExternalScope accepts the `all` alias; the api_key_scope + // enum has no such member. name: "LegacyAllAliasCanonicalized", requested: []string{"all"}, appScope: noAllowlist, @@ -219,8 +203,7 @@ func TestNegotiateScope(t *testing.T) { want: "coder:application_connect", }, { - // The allowlist is canonicalized on the same terms, so the two - // spellings match instead of reading as different scopes. + // The allowlist is canonicalized on the same terms. name: "LegacyAliasInAllowlistCoversCanonicalRequest", requested: []string{"coder:all"}, appScope: sql.NullString{String: "all", Valid: true}, @@ -240,15 +223,14 @@ func TestNegotiateScope(t *testing.T) { want: inCatalog, }, { - // The same holds for the default, which is built from the - // allowlist rather than from the request. + // The same holds for the default built from the allowlist. name: "DuplicateAllowlistEntriesDeduplicated", requested: nil, appScope: sql.NullString{String: inCatalog + " " + inCatalog, Valid: true}, want: inCatalog, }, { - // Two spellings of one scope collapse to a single entry. + // Two spellings of one scope collapse to one entry. name: "AliasAndCanonicalAllowlistEntriesCollapse", requested: nil, appScope: sql.NullString{String: "all coder:all", Valid: true}, @@ -265,9 +247,7 @@ func TestNegotiateScope(t *testing.T) { if test.wantErr != nil { require.ErrorIs(t, err, test.wantErr) assert.Empty(t, got, "a rejected request must not return a persistable scope") - // xerrors repeats the wrapped text unless %w is the final - // verb, which errors.Is cannot catch and a person reading - // error_description sees. + // xerrors repeats the wrapped text unless %w is the final verb. assert.Equal(t, 1, strings.Count(err.Error(), test.wantErr.Error()), "the rejection reason must appear once, not doubled by the wrap") if test.wantErrText != "" { @@ -285,10 +265,9 @@ func TestNegotiateScope(t *testing.T) { } } -// requirePersistableScope asserts that every name in a negotiated scope can -// survive the trip ahead of it: stored as api_key_scope on the authorization -// code, carried to the token, and expanded by RBAC when the key minted from it -// is authorized. Passing the external scope catalog does not imply all three. +// requirePersistableScope asserts that every name in a negotiated scope can be +// stored as an api_key_scope and expanded by RBAC. Passing the external scope +// catalog does not imply either. func requirePersistableScope(t *testing.T, scope string) { t.Helper() @@ -301,9 +280,8 @@ func requirePersistableScope(t *testing.T, scope string) { } } -// Rejection reasons handed to the package's black-box tests, which cannot -// reach the sentinels themselves. Binding here rather than re-typing the -// strings over there keeps a rewording from silently unpinning them. +// Rejection reasons for the package's black-box tests, which cannot reach the +// sentinels themselves. var ( ReasonUnknownScope = errUnknownScope.Error() ReasonNoGrantableScope = errNoGrantableScope.Error() @@ -313,9 +291,8 @@ var ( func TestNoScopeAllowlist(t *testing.T) { t.Parallel() - // Both encodings are produced in the tree today: sql.NullString{} by - // admin-created apps, Valid-with-empty-string by DCR registration that - // sent no scope. + // NULL comes from admin-created apps, '' from DCR registration that sent + // no scope. assert.True(t, noScopeAllowlist(sql.NullString{})) assert.True(t, noScopeAllowlist(sql.NullString{String: "", Valid: true})) assert.False(t, noScopeAllowlist(sql.NullString{String: "coder:workspaces.access", Valid: true})) diff --git a/coderd/oauth2provider/authorize_test.go b/coderd/oauth2provider/authorize_test.go index d05299b99f3..e6273107213 100644 --- a/coderd/oauth2provider/authorize_test.go +++ b/coderd/oauth2provider/authorize_test.go @@ -48,19 +48,17 @@ func TestOAuthConsentFormIncludesCSRFToken(t *testing.T) { assert.Contains(t, body, `id="cancel-link"`) } -// Scope names used by the negotiation tests. Whether a name is in -// rbac.IsExternalScope's curated catalog is the point of each case. +// Scope names used by the negotiation tests. Membership in +// rbac.IsExternalScope's catalog is the point of each case. const ( scopeInCatalog = "coder:workspaces.access" scopeAlsoInCatalog = "coder:templates.build" scopeOutOfCatalog = "some_removed_scope" - // In the catalog, but outside the authority scopeInCatalog carries: that - // composite grants template:read, never template:update. + // In the catalog, but scopeInCatalog grants template:read, never + // template:update. scopeOutOfAllowlist = "template:update" ) -// The callback every app in these tests registers, and the state every request -// sends. const ( appCallbackURL = "https://example.com/callback" authorizeState = "test-authorize-state" @@ -76,8 +74,7 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { }) _ = coderdtest.CreateFirstUser(t, client) - // Each sub-test gets its own app: only one code exists per app/user pair at - // a time. + // Each sub-test gets its own app: only one code exists per app/user pair. seedApp := func(t *testing.T, appScope sql.NullString) database.OAuth2ProviderApp { t.Helper() return dbgen.OAuth2ProviderApp(t, db, database.OAuth2ProviderApp{ @@ -98,8 +95,8 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { requireInvalidScope(t, resp, reasonScopeNotAllowed) }) - // The allowlist bounds authority rather than spelling, so a name it never - // lists is still granted when the permissions behind it are covered. + // The allowlist bounds authority, not spelling: an unlisted name is + // granted when the permissions behind it are covered. t.Run("ScopeCoveredByAllowlistGranted", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -111,8 +108,8 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, "workspace:ssh", persistedCodeScope(ctx, t, db, resp)) }) - // The catalog half of the same guarantee: a name the enforcement layer - // cannot evaluate is rejected on its own terms, not by the allowlist. + // The catalog half: a name the enforcement layer cannot evaluate is + // rejected on its own terms, not by the allowlist. t.Run("UnknownScopeRejected", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -136,9 +133,8 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, allowlist, persistedCodeScope(ctx, t, db, resp)) }) - // rbac.IsExternalScope accepts `all` as a backward-compatible alias, but - // the api_key_scope enum has only `coder:all`. Asserted against the stored - // row, since the column's vocabulary is what the claim is about. + // rbac.IsExternalScope accepts the `all` alias; the api_key_scope enum has + // only `coder:all`. t.Run("LegacyAliasPersistedCanonically", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -163,9 +159,8 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { }) // NULL (admin-created apps) and '' (DCR apps that sent no scope) are one - // "no allowlist configured" state. This also carries the backward - // compatibility guarantee: such an app keeps the unrestricted grant it had - // before scope enforcement existed. + // "no allowlist" state, and both keep the grant they had before scope + // enforcement existed. t.Run("NullAndEmptyAllowlistBehaveIdentically", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -185,8 +180,6 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { }) // An allowlist entry no longer in the catalog is dropped, not granted. - // AllowlistFilteringToEmptyRejected below is the same filter with no - // survivors. t.Run("StaleAllowlistEntryDropped", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -198,8 +191,8 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, scopeInCatalog, persistedCodeScope(ctx, t, db, resp)) }) - // Falling back to unrestricted here would grant strictly more than the - // allowlist ever permitted. + // The same filter with no survivors: falling back to unrestricted would + // grant more than the allowlist ever permitted. t.Run("AllowlistFilteringToEmptyRejected", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -213,10 +206,9 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { } // TestOAuth2AuthorizeDCRScopeCompatibility pins an accepted compatibility -// break: dynamic client registration performs no catalog validation, so an app -// can register an allowlist this server cannot grant from. Requesting those -// scopes and omitting scope entirely both fail loudly with invalid_scope, -// rather than granting one dbauthz has no way to evaluate. +// break: registration performs no catalog validation, so an app can register +// an allowlist this server cannot grant from. Authorization then fails with +// invalid_scope rather than granting a scope dbauthz cannot evaluate. func TestOAuth2AuthorizeDCRScopeCompatibility(t *testing.T) { t.Parallel() @@ -230,7 +222,7 @@ func TestOAuth2AuthorizeDCRScopeCompatibility(t *testing.T) { ClientName: testutil.GetRandomName(t), Scope: "openid profile email", }) - require.NoError(t, err, "registration itself is unchanged: no catalog check happens here") + require.NoError(t, err, "registration performs no catalog check") t.Run("RequestingRegisteredScopeRejected", func(t *testing.T) { t.Parallel() @@ -252,9 +244,8 @@ func TestOAuth2AuthorizeDCRScopeCompatibility(t *testing.T) { requireInvalidScope(t, resp, reasonNoGrantableScope) }) - // Only whoever registered the app can recover from the break, and the - // request that triggers it carries no scope of its own, so the description - // has to name the registered list. + // Only the app owner can recover from the break, and the request carries + // no scope of its own, so the description has to name the registered list. t.Run("RejectionNamesTheRegisteredScopes", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -264,13 +255,12 @@ func TestOAuth2AuthorizeDCRScopeCompatibility(t *testing.T) { body := requireInvalidScope(t, resp, reasonNoGrantableScope) require.Contains(t, body, "openid profile email", - "the app owner cannot act on this without knowing which registered scopes are the problem") + "the rejection must name the registered scopes the owner has to change") }) } -// authorizeQuery builds a well-formed /oauth2/authorize query. Callers needing -// to vary a parameter the happy path does not, such as redirect_uri, mutate -// the result and pass it to sendAuthorizeRequest. +// authorizeQuery builds a well-formed /oauth2/authorize query. Callers varying +// another parameter mutate the result and pass it to sendAuthorizeRequest. func authorizeQuery(t *testing.T, clientID, scope string) url.Values { t.Helper() @@ -288,8 +278,8 @@ func authorizeQuery(t *testing.T, clientID, scope string) url.Values { } // authorizeRequest issues an /oauth2/authorize request for the given app. -// Redirects are not followed, so a successful POST surfaces as a 302 whose -// Location carries the code. +// Redirects are not followed, so a successful POST surfaces as a 302 carrying +// the code in Location. func authorizeRequest(ctx context.Context, t *testing.T, client *codersdk.Client, method, clientID, scope string) *http.Response { t.Helper() @@ -317,8 +307,8 @@ func sendAuthorizeRequest(ctx context.Context, t *testing.T, client *codersdk.Cl return resp } -// persistedCodeScope returns the scope recorded on the code a successful -// authorization issued, which is what the token exchange later reads. +// persistedCodeScope returns the scope stored on the issued code, which is +// what the token exchange later reads. func persistedCodeScope(ctx context.Context, t *testing.T, db database.Store, resp *http.Response) string { t.Helper() @@ -339,32 +329,25 @@ func persistedCodeScope(ctx context.Context, t *testing.T, db database.Store, re // The rejection reasons from authorize.go, each unique to one branch. The // transport carries only the rendered description, so these pin over the wire -// what errors.Is pins in the package's own tests. Binding to the sentinels -// rather than re-typing them keeps a rewording from unpinning a case. +// what errors.Is pins in the package's own tests. var ( reasonUnknownScope = oauth2provider.ReasonUnknownScope reasonNoGrantableScope = oauth2provider.ReasonNoGrantableScope reasonScopeNotAllowed = oauth2provider.ReasonScopeNotAllowed ) -// requireInvalidScope asserts that a rejected request is refused rather than -// issued a code, and that the refusal names the branch the caller expects. +// requireInvalidScope asserts that a request is refused rather than issued a +// code, and that the refusal names the branch the caller expects. // -// Each handler answers in the form it already uses for its own errors: a -// static error page on GET, an OAuth2 error body on POST. Both carry the -// description in the response and neither redirects, so the assertion is on -// status and body rather than on a Location. Delivering these to the client's -// own callback instead is a separate change, which rewrites this helper. -// -// The body is returned because reading it consumes it. It is unescaped so that -// a caller matching on a reason, which contains an apostrophe the GET side's -// template renders as ', need not know which handler answered. +// GET answers with an error page and POST with an OAuth2 error body. Neither +// redirects, so the assertion is on status and body. The returned body is +// unescaped so callers need not know which handler answered. func requireInvalidScope(t *testing.T, resp *http.Response, wantReason string) string { t.Helper() require.Equal(t, http.StatusBadRequest, resp.StatusCode) require.Empty(t, resp.Header.Get("Location"), - "a rejected request must not be redirected anywhere, least of all with a code") + "a rejected request must not be redirected, least of all with a code") body := html.UnescapeString(readBody(t, resp)) require.Contains(t, body, wantReason, diff --git a/coderd/oauth2provider/validation_test.go b/coderd/oauth2provider/validation_test.go index de1ff3a0405..f028a3236af 100644 --- a/coderd/oauth2provider/validation_test.go +++ b/coderd/oauth2provider/validation_test.go @@ -542,11 +542,8 @@ func TestOAuth2ClientNameValidation(t *testing.T) { } // TestOAuth2ClientScopeValidation tests scope validation at registration time. -// -// Registration stores the scope string verbatim without checking it against -// the scope catalog, so every value below is accepted. Authorization enforces -// the catalog: an app whose allowlist names nothing grantable can no longer -// complete a flow, whether it requests a scope or omits one. See +// Registration stores the scope verbatim without a catalog check, so every +// value below is accepted. Authorization enforces the catalog: see // TestOAuth2AuthorizeDCRScopeCompatibility. func TestOAuth2ClientScopeValidation(t *testing.T) { t.Parallel() From 24b4f46d1c37fe040b9826ed961acddd47faf005 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 25 Aug 2026 03:19:50 +0000 Subject: [PATCH 26/33] docs(coderd): drop the godoc-style comments from the scope tests --- coderd/oauth2_metadata_validation_test.go | 5 +- .../oauth2provider/authorize_internal_test.go | 46 ++------------- coderd/oauth2provider/authorize_test.go | 58 ++++++------------- coderd/oauth2provider/validation_test.go | 5 +- 4 files changed, 28 insertions(+), 86 deletions(-) diff --git a/coderd/oauth2_metadata_validation_test.go b/coderd/oauth2_metadata_validation_test.go index 3173fb6eeac..d72cb3af3bd 100644 --- a/coderd/oauth2_metadata_validation_test.go +++ b/coderd/oauth2_metadata_validation_test.go @@ -541,9 +541,8 @@ func TestOAuth2ClientNameValidation(t *testing.T) { } } -// TestOAuth2ClientScopeValidation tests scope validation at registration time. -// Registration stores the scope verbatim without a catalog check, so every -// value below is accepted. Authorization enforces the catalog: see +// Registration stores the scope verbatim, so every value below is accepted. +// The catalog is enforced at authorization: see // TestOAuth2AuthorizeDCRScopeCompatibility in coderd/oauth2provider. func TestOAuth2ClientScopeValidation(t *testing.T) { t.Parallel() diff --git a/coderd/oauth2provider/authorize_internal_test.go b/coderd/oauth2provider/authorize_internal_test.go index 74c18ad1235..83daf5e911a 100644 --- a/coderd/oauth2provider/authorize_internal_test.go +++ b/coderd/oauth2provider/authorize_internal_test.go @@ -19,7 +19,6 @@ import ( func TestNegotiateScope(t *testing.T) { t.Parallel() - // Membership in rbac.IsExternalScope's catalog is the point of most cases. const ( inCatalog = "coder:workspaces.access" alsoInCatalog = "coder:templates.build" @@ -30,8 +29,6 @@ func TestNegotiateScope(t *testing.T) { noAllowlist := sql.NullString{} emptyAllowlist := sql.NullString{String: "", Valid: true} - // wantErr names the branch a rejection must come from; wantErrText pins - // the rendered text. tests := []struct { name string requested []string @@ -47,15 +44,13 @@ func TestNegotiateScope(t *testing.T) { wantErr: errUnknownScope, }, { - // The catalog check does not depend on the allowlist. name: "UnknownRequestedScopeRejectedWithoutAllowlist", requested: []string{"not_a_real_scope"}, appScope: noAllowlist, wantErr: errUnknownScope, }, { - // RBAC can expand debug_info:read; only the catalog keeps it - // out of a client's reach. + // RBAC expands debug_info:read; only the catalog keeps it internal. name: "InternalOnlyScopeRejected", requested: []string{"debug_info:read"}, appScope: noAllowlist, @@ -68,7 +63,6 @@ func TestNegotiateScope(t *testing.T) { want: string(database.ApiKeyScopeCoderAll), }, { - // '' is how DCR encodes the unset state NULL expresses. name: "EmptyAllowlistBehavesAsNoAllowlist", requested: nil, appScope: emptyAllowlist, @@ -105,83 +99,66 @@ func TestNegotiateScope(t *testing.T) { want: alsoInCatalog, }, { - // coder:workspaces.access grants template:read, not template:update. name: "PartiallyOutOfAllowlistRejected", requested: []string{inCatalog, "template:update"}, appScope: sql.NullString{String: inCatalog, Valid: true}, wantErr: errScopeNotAllowed, }, { - // The allowlist bounds authority, not spelling: the composite - // already grants workspace:ssh. name: "LowLevelScopeCoveredByCompositeAllowlistAccepted", requested: []string{"workspace:ssh"}, appScope: sql.NullString{String: inCatalog, Valid: true}, want: "workspace:ssh", }, { - // Coverage is per name, so a mixed request is refused whole. name: "PartiallyCoveredRequestRejectedWhole", requested: []string{"workspace:ssh", "workspace:delete"}, appScope: sql.NullString{String: inCatalog, Valid: true}, wantErr: errScopeNotAllowed, }, { - // The wildcard action is wider than the composite's read half. name: "WildcardActionNotCoveredByCompositeAllowlist", requested: []string{"workspace:*"}, appScope: sql.NullString{String: inCatalog, Valid: true}, wantErr: errScopeNotAllowed, }, { - // coder:all expands to the wildcard resource and action. name: "AllAllowlistCoversAnyScope", requested: []string{"user_secret:delete"}, appScope: sql.NullString{String: string(database.ApiKeyScopeCoderAll), Valid: true}, want: "user_secret:delete", }, { - // The allowlist is one combined ceiling, so a request may draw - // on several entries at once. name: "CoverageSpansMultipleAllowlistEntries", requested: []string{"file:create", "workspace:ssh"}, appScope: sql.NullString{String: inCatalog + " " + alsoInCatalog, Valid: true}, want: "file:create workspace:ssh", }, { - // Catalog drift: the stale entry is dropped, the survivor granted. name: "StaleAllowlistEntryDroppedNotGranted", requested: nil, appScope: sql.NullString{String: inCatalog + " " + notInCatalog, Valid: true}, want: inCatalog, }, { - // The catalog check runs before the allowlist, so the reason is - // errUnknownScope. name: "StaleAllowlistEntryNotRequestableExplicitly", requested: []string{notInCatalog}, appScope: sql.NullString{String: inCatalog + " " + notInCatalog, Valid: true}, wantErr: errUnknownScope, }, { - // Falling back to unrestricted would grant more than the - // allowlist ever permitted. name: "AllowlistFilteringToEmptyRejected", requested: nil, appScope: sql.NullString{String: "openid profile email", Valid: true}, wantErr: errNoGrantableScope, }, { - // The accepted compatibility break: a DCR client requesting - // exactly what it registered. - name: "NonCatalogScopeRequestedAsRegistered", + name: "RegisteredNonCatalogScopeRejected", requested: []string{neverInCatalog}, appScope: sql.NullString{String: neverInCatalog, Valid: true}, wantErr: errUnknownScope, }, { - // Configured but grants nothing. The text is pinned because every - // entry vanishes before the message is built. name: "WhitespaceOnlyAllowlistRejected", requested: nil, appScope: sql.NullString{String: " ", Valid: true}, @@ -189,8 +166,6 @@ func TestNegotiateScope(t *testing.T) { wantErrText: `" "`, }, { - // rbac.IsExternalScope accepts the `all` alias; the api_key_scope - // enum has no such member. name: "LegacyAllAliasCanonicalized", requested: []string{"all"}, appScope: noAllowlist, @@ -203,7 +178,6 @@ func TestNegotiateScope(t *testing.T) { want: "coder:application_connect", }, { - // The allowlist is canonicalized on the same terms. name: "LegacyAliasInAllowlistCoversCanonicalRequest", requested: []string{"coder:all"}, appScope: sql.NullString{String: "all", Valid: true}, @@ -216,21 +190,18 @@ func TestNegotiateScope(t *testing.T) { want: "coder:all", }, { - // A space-separated scope denotes a set. name: "DuplicateRequestedScopesDeduplicated", requested: []string{inCatalog, inCatalog}, appScope: noAllowlist, want: inCatalog, }, { - // The same holds for the default built from the allowlist. name: "DuplicateAllowlistEntriesDeduplicated", requested: nil, appScope: sql.NullString{String: inCatalog + " " + inCatalog, Valid: true}, want: inCatalog, }, { - // Two spellings of one scope collapse to one entry. name: "AliasAndCanonicalAllowlistEntriesCollapse", requested: nil, appScope: sql.NullString{String: "all coder:all", Valid: true}, @@ -247,7 +218,6 @@ func TestNegotiateScope(t *testing.T) { if test.wantErr != nil { require.ErrorIs(t, err, test.wantErr) assert.Empty(t, got, "a rejected request must not return a persistable scope") - // xerrors repeats the wrapped text unless %w is the final verb. assert.Equal(t, 1, strings.Count(err.Error(), test.wantErr.Error()), "the rejection reason must appear once, not doubled by the wrap") if test.wantErrText != "" { @@ -258,16 +228,14 @@ func TestNegotiateScope(t *testing.T) { } require.NoError(t, err) assert.Equal(t, test.want, got) - // The column is NOT NULL with CHECK (scope <> ''). - assert.NotEmpty(t, got, "a successful negotiation must never return an empty scope") + assert.NotEmpty(t, got, "the code scope column is NOT NULL with CHECK (scope <> '')") requirePersistableScope(t, got) }) } } -// requirePersistableScope asserts that every name in a negotiated scope can be -// stored as an api_key_scope and expanded by RBAC. Passing the external scope -// catalog does not imply either. +// requirePersistableScope asserts every name can be stored as an api_key_scope +// and expanded by RBAC. Passing the external catalog implies neither. func requirePersistableScope(t *testing.T, scope string) { t.Helper() @@ -281,7 +249,7 @@ func requirePersistableScope(t *testing.T, scope string) { } // Rejection reasons for the package's black-box tests, which cannot reach the -// sentinels themselves. +// sentinels. var ( ReasonUnknownScope = errUnknownScope.Error() ReasonNoGrantableScope = errNoGrantableScope.Error() @@ -291,8 +259,6 @@ var ( func TestNoScopeAllowlist(t *testing.T) { t.Parallel() - // NULL comes from admin-created apps, '' from DCR registration that sent - // no scope. assert.True(t, noScopeAllowlist(sql.NullString{})) assert.True(t, noScopeAllowlist(sql.NullString{String: "", Valid: true})) assert.False(t, noScopeAllowlist(sql.NullString{String: "coder:workspaces.access", Valid: true})) diff --git a/coderd/oauth2provider/authorize_test.go b/coderd/oauth2provider/authorize_test.go index e6273107213..2f0b6df63a7 100644 --- a/coderd/oauth2provider/authorize_test.go +++ b/coderd/oauth2provider/authorize_test.go @@ -48,8 +48,6 @@ func TestOAuthConsentFormIncludesCSRFToken(t *testing.T) { assert.Contains(t, body, `id="cancel-link"`) } -// Scope names used by the negotiation tests. Membership in -// rbac.IsExternalScope's catalog is the point of each case. const ( scopeInCatalog = "coder:workspaces.access" scopeAlsoInCatalog = "coder:templates.build" @@ -74,7 +72,7 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { }) _ = coderdtest.CreateFirstUser(t, client) - // Each sub-test gets its own app: only one code exists per app/user pair. + // Only one code exists per app/user pair, so each sub-test needs its own app. seedApp := func(t *testing.T, appScope sql.NullString) database.OAuth2ProviderApp { t.Helper() return dbgen.OAuth2ProviderApp(t, db, database.OAuth2ProviderApp{ @@ -95,9 +93,7 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { requireInvalidScope(t, resp, reasonScopeNotAllowed) }) - // The allowlist bounds authority, not spelling: an unlisted name is - // granted when the permissions behind it are covered. - t.Run("ScopeCoveredByAllowlistGranted", func(t *testing.T) { + t.Run("UnlistedScopeCoveredByAllowlistGranted", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -108,8 +104,6 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, "workspace:ssh", persistedCodeScope(ctx, t, db, resp)) }) - // The catalog half: a name the enforcement layer cannot evaluate is - // rejected on its own terms, not by the allowlist. t.Run("UnknownScopeRejected", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -133,8 +127,6 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, allowlist, persistedCodeScope(ctx, t, db, resp)) }) - // rbac.IsExternalScope accepts the `all` alias; the api_key_scope enum has - // only `coder:all`. t.Run("LegacyAliasPersistedCanonically", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -146,7 +138,6 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, string(database.ApiKeyScopeCoderAll), persistedCodeScope(ctx, t, db, resp)) }) - // A space-separated scope denotes a set. t.Run("DuplicateRequestedScopePersistedOnce", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -158,10 +149,8 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, scopeInCatalog, persistedCodeScope(ctx, t, db, resp)) }) - // NULL (admin-created apps) and '' (DCR apps that sent no scope) are one - // "no allowlist" state, and both keep the grant they had before scope - // enforcement existed. - t.Run("NullAndEmptyAllowlistBehaveIdentically", func(t *testing.T) { + // NULL comes from admin-created apps, '' from DCR apps that sent no scope. + t.Run("NullAndEmptyAllowlistGrantUnrestricted", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -179,8 +168,7 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, nullScope, emptyScope) }) - // An allowlist entry no longer in the catalog is dropped, not granted. - t.Run("StaleAllowlistEntryDropped", func(t *testing.T) { + t.Run("StaleAllowlistEntryDroppedNotGranted", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -191,8 +179,6 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { require.Equal(t, scopeInCatalog, persistedCodeScope(ctx, t, db, resp)) }) - // The same filter with no survivors: falling back to unrestricted would - // grant more than the allowlist ever permitted. t.Run("AllowlistFilteringToEmptyRejected", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -205,10 +191,9 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { }) } -// TestOAuth2AuthorizeDCRScopeCompatibility pins an accepted compatibility -// break: registration performs no catalog validation, so an app can register -// an allowlist this server cannot grant from. Authorization then fails with -// invalid_scope rather than granting a scope dbauthz cannot evaluate. +// Registration performs no catalog validation, so an app can register an +// allowlist this server cannot grant from. Authorization then rejects it rather +// than granting a scope dbauthz cannot evaluate. func TestOAuth2AuthorizeDCRScopeCompatibility(t *testing.T) { t.Parallel() @@ -244,8 +229,6 @@ func TestOAuth2AuthorizeDCRScopeCompatibility(t *testing.T) { requireInvalidScope(t, resp, reasonNoGrantableScope) }) - // Only the app owner can recover from the break, and the request carries - // no scope of its own, so the description has to name the registered list. t.Run("RejectionNamesTheRegisteredScopes", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -260,7 +243,7 @@ func TestOAuth2AuthorizeDCRScopeCompatibility(t *testing.T) { } // authorizeQuery builds a well-formed /oauth2/authorize query. Callers varying -// another parameter mutate the result and pass it to sendAuthorizeRequest. +// another parameter mutate the result before sending it. func authorizeQuery(t *testing.T, clientID, scope string) url.Values { t.Helper() @@ -277,9 +260,8 @@ func authorizeQuery(t *testing.T, clientID, scope string) url.Values { return query } -// authorizeRequest issues an /oauth2/authorize request for the given app. -// Redirects are not followed, so a successful POST surfaces as a 302 carrying -// the code in Location. +// authorizeRequest issues an /oauth2/authorize request without following +// redirects, so a successful POST surfaces as a 302 carrying the code. func authorizeRequest(ctx context.Context, t *testing.T, client *codersdk.Client, method, clientID, scope string) *http.Response { t.Helper() @@ -307,8 +289,8 @@ func sendAuthorizeRequest(ctx context.Context, t *testing.T, client *codersdk.Cl return resp } -// persistedCodeScope returns the scope stored on the issued code, which is -// what the token exchange later reads. +// persistedCodeScope returns the scope stored on the issued code, which is what +// the token exchange later reads. func persistedCodeScope(ctx context.Context, t *testing.T, db database.Store, resp *http.Response) string { t.Helper() @@ -327,21 +309,17 @@ func persistedCodeScope(ctx context.Context, t *testing.T, db database.Store, re return code.Scope } -// The rejection reasons from authorize.go, each unique to one branch. The -// transport carries only the rendered description, so these pin over the wire -// what errors.Is pins in the package's own tests. +// The rejection reasons from authorize.go, each unique to one branch. The wire +// carries only the rendered description, so these stand in for errors.Is. var ( reasonUnknownScope = oauth2provider.ReasonUnknownScope reasonNoGrantableScope = oauth2provider.ReasonNoGrantableScope reasonScopeNotAllowed = oauth2provider.ReasonScopeNotAllowed ) -// requireInvalidScope asserts that a request is refused rather than issued a -// code, and that the refusal names the branch the caller expects. -// -// GET answers with an error page and POST with an OAuth2 error body. Neither -// redirects, so the assertion is on status and body. The returned body is -// unescaped so callers need not know which handler answered. +// requireInvalidScope asserts the request was refused by the named branch +// rather than issued a code. GET answers with an error page and POST with an +// OAuth2 error body, so the returned body is unescaped for either. func requireInvalidScope(t *testing.T, resp *http.Response, wantReason string) string { t.Helper() diff --git a/coderd/oauth2provider/validation_test.go b/coderd/oauth2provider/validation_test.go index f028a3236af..e02d1ce3441 100644 --- a/coderd/oauth2provider/validation_test.go +++ b/coderd/oauth2provider/validation_test.go @@ -541,9 +541,8 @@ func TestOAuth2ClientNameValidation(t *testing.T) { } } -// TestOAuth2ClientScopeValidation tests scope validation at registration time. -// Registration stores the scope verbatim without a catalog check, so every -// value below is accepted. Authorization enforces the catalog: see +// Registration stores the scope verbatim, so every value below is accepted. +// The catalog is enforced at authorization: see // TestOAuth2AuthorizeDCRScopeCompatibility. func TestOAuth2ClientScopeValidation(t *testing.T) { t.Parallel() From ceb3997bfb1bf9b74ecda74c68fac53253c38046 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 31 Aug 2026 07:49:57 -0700 Subject: [PATCH 27/33] Update coderd/oauth2provider/authorize.go Co-authored-by: Steven Masley --- coderd/oauth2provider/authorize.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index 26c51a1739f..a52c62fa2e8 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -27,10 +27,7 @@ import ( "github.com/coder/coder/v2/site" ) -// Rejection reasons from negotiateScope. These are rendered into -// error_description, and each is wrapped as `%q: %w` with the offending value -// ahead of it: xerrors only avoids repeating the sentinel's own text when %w -// is the final verb. +// Rejection reasons from negotiateScope. var ( // errUnknownScope covers a requested name outside the external scope // catalog, whether unrecognized entirely or recognized but internal-only. From 2cd9b60846d133991d72b0ea9a511ac0b22bbe7a Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 31 Aug 2026 07:51:58 -0700 Subject: [PATCH 28/33] Update coderd/oauth2provider/authorize.go Co-authored-by: Steven Masley --- coderd/oauth2provider/authorize.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index a52c62fa2e8..70b468d9655 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -36,9 +36,9 @@ var ( // the catalog. The remedy is left unprescribed: an admin edits the app, a // dynamically registered client updates itself through RFC 7592. errNoGrantableScope = xerrors.New("none of the scopes registered for this app are supported by this deployment; change the app's registered scopes to supported ones") - // errScopeNotAllowed is phrased as coverage rather than list membership, - // because a scope the allowlist does not name is still granted when a - // listed composite already confers it. +// errScopeNotAllowed checks whether the scope's expanded permissions are +// covered by the allowlist. For example, "coder:workspaces.create" expands +// to several workspace permissions. errScopeNotAllowed = xerrors.New("scope requests permissions beyond this app's allowed scopes") // errCoverageUndecidable covers a comparison that failed outright. The // underlying error names RBAC internals, so it is logged rather than From d160bdac3c9fbd70d5b4c7701e1201861c376614 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 31 Aug 2026 14:59:15 +0000 Subject: [PATCH 29/33] docs(coderd/oauth2provider): trim the errNoGrantableScope comment --- coderd/oauth2provider/authorize.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index 70b468d9655..336b4df91d0 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -33,12 +33,11 @@ var ( // catalog, whether unrecognized entirely or recognized but internal-only. errUnknownScope = xerrors.New("unknown or unsupported scope") // errNoGrantableScope covers an allowlist whose every entry falls outside - // the catalog. The remedy is left unprescribed: an admin edits the app, a - // dynamically registered client updates itself through RFC 7592. + // the catalog. errNoGrantableScope = xerrors.New("none of the scopes registered for this app are supported by this deployment; change the app's registered scopes to supported ones") -// errScopeNotAllowed checks whether the scope's expanded permissions are -// covered by the allowlist. For example, "coder:workspaces.create" expands -// to several workspace permissions. + // errScopeNotAllowed checks whether the scope's expanded permissions are + // covered by the allowlist. For example, "coder:workspaces.create" expands + // to several workspace permissions. errScopeNotAllowed = xerrors.New("scope requests permissions beyond this app's allowed scopes") // errCoverageUndecidable covers a comparison that failed outright. The // underlying error names RBAC internals, so it is logged rather than From b60eee6b099c63d9be74764cf40ca8a52ced64b0 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 31 Aug 2026 15:28:11 +0000 Subject: [PATCH 30/33] refactor(coderd/oauth2provider): canonicalize scopes before the catalog check --- coderd/oauth2provider/authorize.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index 336b4df91d0..0d88bcb045d 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -95,22 +95,25 @@ func noScopeAllowlist(appScope sql.NullString) bool { // the empty string, so it is never empty alongside a nil error, and its names // are canonical api_key_scope spellings carrying no duplicates. func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2ProviderApp, requested []string) (string, error) { + // Canonicalized before the catalog check so that check, the coverage + // comparison, and the persisted value all read one vocabulary. Rewriting + // ahead of validation loses nothing: CanonicalScopeName only touches the + // `all` and `application_connect` aliases, and the catalog holds both + // spellings of each. + granted := canonicalScopes(requested) + // The catalog is a curation, not a validity check: RBAC can expand // internal-only names such as debug_info:read, and the api_key_scope enum // would store them. Only catalog names are client-requestable, whether or // not the app has an allowlist to check them against. - for _, s := range requested { + for _, s := range granted { if !rbac.IsExternalScope(rbac.ScopeName(s)) { return "", xerrors.Errorf("%q: %w", s, errUnknownScope) } } - // Canonicalized after the catalog check, so a rejection names the scope - // as the client spelled it rather than as the server stores it. - granted := canonicalScopes(requested) - if noScopeAllowlist(app.Scope) { - if len(requested) == 0 { + if len(granted) == 0 { // Unrestricted, the same grant this app got before scope // enforcement existed, stated explicitly because an empty string // would violate the column's CHECK. @@ -142,7 +145,7 @@ func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2 // and not the `all` alias that IsExternalScope accepts. filtered = canonicalScopes(filtered) - if len(requested) == 0 { + if len(granted) == 0 { return strings.Join(filtered, " "), nil // RFC 6749 §3.3 default } From 4732d1f89f24a94c255260b6bdc2c9226fcaa255 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 31 Aug 2026 15:43:18 +0000 Subject: [PATCH 31/33] refactor(coderd/oauth2provider): canonicalize the allowlist in one pass --- coderd/oauth2provider/authorize.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index 0d88bcb045d..ecec25aac39 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -125,13 +125,17 @@ func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2 // The allowlist was stored at registration time and may name a scope since // removed from the catalog, or never in it. Filtering only ever narrows // what is granted. + // + // Canonicalized in the same pass so both sides expand: rbac.ExpandScope + // knows `coder:all` and not the `all` alias that IsExternalScope accepts. allowed := strings.Fields(app.Scope.String) - filtered := make([]string, 0, len(allowed)) + filtered := make([]rbac.ScopeName, 0, len(allowed)) for _, a := range allowed { - if rbac.IsExternalScope(rbac.ScopeName(a)) { - filtered = append(filtered, a) + if name := rbac.ScopeName(a); rbac.IsExternalScope(name) { + filtered = append(filtered, rbac.CanonicalScopeName(name)) } } + filtered = slice.Unique(filtered) if len(filtered) == 0 { // Falling through to the no-allowlist branch would grant strictly more // than this allowlist ever permitted. @@ -141,24 +145,21 @@ func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2 // as "" for the one configuration that most needs naming. return "", xerrors.Errorf("%q: %w", app.Scope.String, errNoGrantableScope) } - // Canonicalized so both sides expand: rbac.ExpandScope knows `coder:all` - // and not the `all` alias that IsExternalScope accepts. - filtered = canonicalScopes(filtered) if len(granted) == 0 { - return strings.Join(filtered, " "), nil // RFC 6749 §3.3 default + names := make([]string, 0, len(filtered)) + for _, name := range filtered { + names = append(names, string(name)) + } + return strings.Join(names, " "), nil // RFC 6749 §3.3 default } // The allowlist is a ceiling on authority, not a menu of spellings, so the // check is permission coverage rather than name membership: an app allowed // `coder:workspaces.access` can approve a client asking only for // `workspace:read`, which that composite already grants. - allowedNames := make([]rbac.ScopeName, 0, len(filtered)) - for _, a := range filtered { - allowedNames = append(allowedNames, rbac.ScopeName(a)) - } for _, s := range granted { - covered, err := rbac.ScopesCover(allowedNames, rbac.ScopeName(s)) + covered, err := rbac.ScopesCover(filtered, rbac.ScopeName(s)) if err != nil { // Refuse rather than grant on an incomplete comparison. The // underlying error names RBAC internals the client can do nothing From 03df6a929abc26bb038bbf3f0974df0eae90c7b5 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 31 Aug 2026 15:51:31 +0000 Subject: [PATCH 32/33] test(coderd/rbac): pin the field sets scope coverage reads --- coderd/rbac/scopes_internal_test.go | 61 +++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/coderd/rbac/scopes_internal_test.go b/coderd/rbac/scopes_internal_test.go index bb20f63e2d6..f30118a734b 100644 --- a/coderd/rbac/scopes_internal_test.go +++ b/coderd/rbac/scopes_internal_test.go @@ -1,6 +1,7 @@ package rbac import ( + "reflect" "testing" "github.com/stretchr/testify/require" @@ -165,3 +166,63 @@ func TestScopesCoverGuards(t *testing.T) { }) } } + +// TestCoverageModelFields pins the shape of every type the coverage comparison +// reads. checkCoverable refuses the authority coverage does not model, but it +// can only refuse what it knows to look at: a field added to one of these types +// would pass the guards unread, and ScopesCover would answer from a fraction of +// what the scope grants without any test failing. +// +// Updating the list is the point rather than the chore. A failure here asks +// whether the new field can carry authority, and if it can, checkCoverable or +// permissionCovered has to account for it first. +func TestCoverageModelFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + typ reflect.Type + fields []string + }{ + { + // permissionCovered compares ResourceType and Action; Negate is + // why checkCoverable refuses a scope carrying one. + name: "Permission", + typ: reflect.TypeOf(Permission{}), + fields: []string{"Negate", "ResourceType", "Action"}, + }, + { + // Scope embeds Role, so Role is where Site, User, and ByOrgID + // reach the guards. + name: "Role", + typ: reflect.TypeOf(Role{}), + fields: []string{"Identifier", "DisplayName", "Site", "User", "ByOrgID", "cachedRegoValue"}, + }, + { + name: "Scope", + typ: reflect.TypeOf(Scope{}), + fields: []string{"Role", "AllowIDList"}, + }, + { + // allowListContainsAll reads both fields to decide whether the + // allow list leaves the Site permissions unconditional. + name: "AllowListElement", + typ: reflect.TypeOf(AllowListElement{}), + fields: []string{"ID", "Type"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got := make([]string, 0, test.typ.NumField()) + for i := range test.typ.NumField() { + got = append(got, test.typ.Field(i).Name) + } + + require.ElementsMatchf(t, test.fields, got, + "%s changed shape: decide whether the new field carries authority coverage must model, extend checkCoverable if it does, then update this list", test.name) + }) + } +} From a2d285643c6ffcdc9cf15f8c85ebf64da2ef951d Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 31 Aug 2026 15:57:14 +0000 Subject: [PATCH 33/33] docs(coderd/rbac): trim the TestCoverageModelFields comment --- coderd/rbac/scopes_internal_test.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/coderd/rbac/scopes_internal_test.go b/coderd/rbac/scopes_internal_test.go index f30118a734b..d59c68bfb36 100644 --- a/coderd/rbac/scopes_internal_test.go +++ b/coderd/rbac/scopes_internal_test.go @@ -167,15 +167,8 @@ func TestScopesCoverGuards(t *testing.T) { } } -// TestCoverageModelFields pins the shape of every type the coverage comparison -// reads. checkCoverable refuses the authority coverage does not model, but it -// can only refuse what it knows to look at: a field added to one of these types -// would pass the guards unread, and ScopesCover would answer from a fraction of -// what the scope grants without any test failing. -// -// Updating the list is the point rather than the chore. A failure here asks -// whether the new field can carry authority, and if it can, checkCoverable or -// permissionCovered has to account for it first. +// TestCoverageModelFields fails when one of the types coverage reads grows a +// field, so someone decides whether checkCoverable has to account for it. func TestCoverageModelFields(t *testing.T) { t.Parallel()