Skip to content

feat: negotiate and persist authorization scope - #28178

Draft
BobbyHo wants to merge 1 commit into
plat479-1-rbac-scope-coveragefrom
plat479-2-negotiate-scope
Draft

feat: negotiate and persist authorization scope#28178
BobbyHo wants to merge 1 commit into
plat479-1-rbac-scope-coveragefrom
plat479-2-negotiate-scope

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Split out of #28045 (PLAT-479). Second of three, stacked on #28167. Review that one first; this diff is against it, not against main.

The authorize endpoint parsed scope and threw it away, so an app's allowlist never restricted anything and a client asking for too much was never told no. Phase 1 added the columns that carry a negotiated scope from a code to the token, but nothing wrote one.

What changes

  • Requested names must be in the external scope catalog. The app's stored allowlist is filtered through that same catalog, which only ever narrows.
  • The allowlist bounds authority, not spelling. A request is granted when every permission it grants is also granted by the allowlist, named or not, so an app allowed coder:workspaces.access can approve a client asking only for workspace:ssh.
  • Omitting scope grants the filtered allowlist (RFC 6749 §3.3).
  • Both handlers negotiate, so a request that cannot succeed fails before the consent page renders rather than after the user clicks Allow.
  • Two paths produce an empty result and stay 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 filtering to nothing is rejected, since falling back would grant more than it ever permitted.

Accepted compatibility break. DCR does no catalog validation, so apps registered with openid, read 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 error now for a silent hole later. The rejection names the registered scopes and the remedy. Pinned in TestOAuth2AuthorizeDCRScopeCompatibility.

Interim error delivery, replaced in PR 3. Each handler reports the rejection the way it already reports its own errors: a static error page on GET, an OAuth2 error body on POST. Delivering these to the client's own callback per RFC 6749 §4.1.2.1 is the next PR, which rewrites the requireInvalidScope helper along with it. About 25 lines here are interim on purpose and flagged as such in that helper.

Not changed. 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.

Stack: #28167, this PR, then the consent page and the invalid_scope redirect.

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.
@github-actions

Copy link
Copy Markdown

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

@BobbyHo BobbyHo changed the title feat(coderd/oauth2provider): negotiate and persist authorization scope feat: negotiate and persist authorization scope Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant