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

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
a672066
feat(coderd/rbac): compare scopes by permission coverage
BobbyHo Aug 14, 2026
4315706
Merge branch 'main' into plat479-1-rbac-scope-coverage
BobbyHo Aug 17, 2026
787c461
docs(coderd/rbac): shorten the ScopesCover doc comment
BobbyHo Aug 17, 2026
2d39e04
Merge branch 'main' into plat479-1-rbac-scope-coverage
BobbyHo Aug 18, 2026
2f6c44e
fix(coderd/rbac): guard allowed-side org and user permissions
BobbyHo Aug 18, 2026
bd40270
refactor(coderd/rbac): drop the unreachable negative skip in coverage
BobbyHo Aug 18, 2026
3139c54
test(coderd/rbac): pin the scope coverage table's weak assertions
BobbyHo Aug 18, 2026
9276bb8
refactor(coderd/rbac): make the coverage guards reachable from tests
BobbyHo Aug 18, 2026
865eb9a
refactor(coderd/rbac): share the alias table and name the canonical c…
BobbyHo Aug 18, 2026
26a6bed
docs(coderd/rbac): document the expansion invariant where it can be b…
BobbyHo Aug 18, 2026
7cca7b3
Merge branch 'main' into plat479-1-rbac-scope-coverage
BobbyHo Aug 19, 2026
1678a77
test(coderd/rbac): pin the coverage guards at one strength
BobbyHo Aug 19, 2026
aba3c6c
fix(coderd/rbac): name the scope once in expansion errors
BobbyHo Aug 19, 2026
9a08105
docs(coderd/rbac): correct the external scope list contract
BobbyHo Aug 19, 2026
2fcce8b
docs(coderd/rbac): trim the restated coverage invariant
BobbyHo Aug 19, 2026
a775a48
docs(coderd/rbac): correct the negative permission cross-reference
BobbyHo Aug 19, 2026
e0c0d4a
docs(coderd/rbac): name every category IsExternalScope admits
BobbyHo Aug 19, 2026
189740d
test(coderd/rbac): pin the alias list invariants on the alias table
BobbyHo Aug 19, 2026
7d08e49
Merge branch 'main' into plat479-1-rbac-scope-coverage
BobbyHo Aug 19, 2026
ca4dc52
feat(coderd/oauth2provider): negotiate and persist authorization scope
BobbyHo Aug 14, 2026
554a703
Merge branch 'main' into plat479-2-negotiate-scope
BobbyHo Aug 20, 2026
09bc1b4
refactor(coderd/oauth2provider): rename validateRequestedScope to neg…
BobbyHo Aug 21, 2026
6fe6c71
test(coderd/oauth2provider): bind the wire-level scope reasons to the…
BobbyHo Aug 21, 2026
e296dda
refactor(coderd): log undecidable oauth2 scope coverage instead of re…
BobbyHo Aug 21, 2026
2760db5
fix(coderd/oauth2provider): name the stored allowlist when none of it…
BobbyHo Aug 21, 2026
5b2ca57
refactor(coderd/oauth2provider): reword the oauth2 scope rejection re…
BobbyHo Aug 21, 2026
90a6f83
docs: correct what the oauth2 authorize scope parameter promises
BobbyHo Aug 21, 2026
77654a1
test(coderd/oauth2provider): fold the no-allowlist guarantee into one…
BobbyHo Aug 21, 2026
ba8a123
Merge branch 'main' into plat479-2-negotiate-scope
BobbyHo Aug 22, 2026
81bbfb0
docs: trim OAuth2 scope negotiation comments
BobbyHo Aug 23, 2026
2a2e006
Merge branch 'main' into plat479-2-negotiate-scope
BobbyHo Aug 23, 2026
e63ff3a
docs(coderd): trim the scope test comments
BobbyHo Aug 25, 2026
24b4f46
docs(coderd): drop the godoc-style comments from the scope tests
BobbyHo Aug 25, 2026
78fbab1
Merge branch 'main' into plat479-2-negotiate-scope
BobbyHo Aug 26, 2026
ceb3997
Update coderd/oauth2provider/authorize.go
BobbyHo Aug 31, 2026
2cd9b60
Update coderd/oauth2provider/authorize.go
BobbyHo Aug 31, 2026
d160bda
docs(coderd/oauth2provider): trim the errNoGrantableScope comment
BobbyHo Aug 31, 2026
b60eee6
refactor(coderd/oauth2provider): canonicalize scopes before the catal…
BobbyHo Aug 31, 2026
4732d1f
refactor(coderd/oauth2provider): canonicalize the allowlist in one pass
BobbyHo Aug 31, 2026
03df6a9
test(coderd/rbac): pin the field sets scope coverage reads
BobbyHo Aug 31, 2026
a2d2856
docs(coderd/rbac): trim the TestCoverageModelFields comment
BobbyHo Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions coderd/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,11 @@ 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. 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 {
return oauth2provider.ShowAuthorizePage(api.AccessURL)
return oauth2provider.ShowAuthorizePage(api.AccessURL, api.Logger)
}

// @Summary OAuth2 authorization request (POST - process authorization).
Expand All @@ -135,11 +135,11 @@ 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. 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 {
return oauth2provider.ProcessAuthorize(api.Database)
return oauth2provider.ProcessAuthorize(api.Database, api.Logger)
}

// @Summary OAuth2 token exchange.
Expand Down
6 changes: 4 additions & 2 deletions coderd/oauth2_metadata_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,9 @@ func TestOAuth2ClientNameValidation(t *testing.T) {
}
}

// TestOAuth2ClientScopeValidation tests scope parameter validation
// 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()

Expand Down Expand Up @@ -598,7 +600,7 @@ func TestOAuth2ClientScopeValidation(t *testing.T) {
{
name: "InvalidAdmin",
scope: "admin",
expectError: false, // Admin scope should be allowed but validated during authorization
expectError: false, // Rejected at authorization, not registration.
},
{
name: "ValidCustom",
Expand Down
197 changes: 189 additions & 8 deletions coderd/oauth2provider/authorize.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package oauth2provider

import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
Expand All @@ -15,14 +16,168 @@ 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"
"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 negotiateScope.
var (
// 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 covers an allowlist whose every entry falls outside
// 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")
Comment thread
BobbyHo marked this conversation as resolved.
// 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
// 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. It
// neither validates nor filters: callers check rbac.IsExternalScope separately.
//
// 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 {
Comment thread
BobbyHo marked this conversation as resolved.
Comment thread
BobbyHo marked this conversation as resolved.
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: 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
// negotiateScope's filtered-to-empty rejection instead of the unrestricted
// fallback.
func noScopeAllowlist(appScope sql.NullString) bool {
Comment thread
BobbyHo marked this conversation as resolved.
return !appScope.Valid || appScope.String == ""
}

// negotiateScope decides the scope the authorization code will carry. Every
// 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:
//
// allowlist request result
// absent absent ApiKeyScopeCoderAll, the pre-enforcement grant
// absent present the request, which is narrower than unrestricted
// present absent the allowlist, catalog-filtered (RFC 6749 §3.3 default)
// present present the request, once shown to be within the allowlist
//
// 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 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) {
// 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 granted {
if !rbac.IsExternalScope(rbac.ScopeName(s)) {
return "", xerrors.Errorf("%q: %w", s, errUnknownScope)
}
Comment thread
BobbyHo marked this conversation as resolved.
}

if noScopeAllowlist(app.Scope) {
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.
return string(database.ApiKeyScopeCoderAll), nil
}
return strings.Join(granted, " "), nil
}

// 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([]rbac.ScopeName, 0, len(allowed))
for _, a := range allowed {
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.
//
// 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)
}

if len(granted) == 0 {
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.
for _, s := range granted {
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
Comment thread
BobbyHo marked this conversation as resolved.
// 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()),
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)
}
}
return strings.Join(granted, " "), nil
}

type authorizeParams struct {
clientID string
redirectURL *url.URL
Expand Down Expand Up @@ -96,7 +251,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())
Expand Down Expand Up @@ -156,6 +311,26 @@ func ShowAuthorizePage(accessURL *url.URL) http.HandlerFunc {
return
}

// 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,
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")
Expand Down Expand Up @@ -197,7 +372,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)
Expand Down Expand Up @@ -234,7 +409,13 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc {
return
}

// TODO: Ignoring scope for now, but should look into implementing.
grantedScope, err := negotiateScope(ctx, logger, app, params.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")
Expand Down Expand Up @@ -271,11 +452,11 @@ 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. 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,
Comment thread
BobbyHo marked this conversation as resolved.
})
if err != nil {
return xerrors.Errorf("insert oauth2 authorization code: %w", err)
Expand Down
Loading
Loading