Skip to content

feat: add oauth2 scope columns and single-use delete queries - #28007

Open
BobbyHo wants to merge 7 commits into
mainfrom
coder-oauth2-scope-enforcement-plat-470
Open

feat: add oauth2 scope columns and single-use delete queries#28007
BobbyHo wants to merge 7 commits into
mainfrom
coder-oauth2-scope-enforcement-plat-470

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

OAuth2 tokens issued by Coder ignore scope entirely. /oauth2/authorize parses the scope parameter and then discards it (// TODO: Ignoring scope for now), and both grant functions mint API keys with no Scopes set while performing the exchange as rbac.ScopeAll, so every token gets full API access regardless of what the client requested or what the app's allowlist permits. There is also nowhere to put a negotiated scope: nothing in the pipeline carries one from the authorize step to the token it produces.

This PR is the schema and query groundwork for that pipeline and changes no behavior on its own. Migration 000567 adds a nullable scope text to oauth2_provider_app_codes and oauth2_provider_app_tokens, so a negotiated scope can travel from a code to the token it is exchanged for, and from a token to its refreshed successor. It also adds DeleteOAuth2ProviderAppCodeByIDReturningID and DeleteAPIKeyByIDReturningID, which return sql.ErrNoRows when the row is already gone: Postgres serializes concurrent deletes on the row lock, so exactly one caller gets a row back, which is what will let the grant paths enforce single use of a code or refresh token without a read-then-write race. The existing blind :exec deletes and all of their call sites are untouched, and every insert writes NULL for the new column, which the token endpoint reads as unrestricted access, so current behavior is preserved exactly.

Phase 1 of PLAT-470, tracked as PLAT-478. Scope validation at /oauth2/authorize, applying the negotiated scope in authorizationCodeGrant, and refresh narrowing plus wiring in the two new deletes follow as separate PRs.

End-to-end scope enforcement flow (green marks what this PR touches)
flowchart TD
    subgraph authorize["/oauth2/authorize"]
        AZ1["ShowAuthorizePage (GET)<br/>renders consent page"]
        AZ2["ProcessAuthorize (POST)<br/>scope parsed, then discarded"]
        Q1["InsertOAuth2ProviderAppCode<br/>gains a Scope param"]
        AZ1 --> AZ2 --> Q1
    end

    Q1 --> CODES[("oauth2_provider_app_codes<br/>new column: scope text NULL")]

    subgraph codegrant["POST /oauth2/token, grant_type=authorization_code"]
        G1["authorizationCodeGrant"]
        Q2["GetOAuth2ProviderAppCodeByPrefix<br/>now returns Scope"]
        Q4["DeleteOAuth2ProviderAppCodeByIDReturningID<br/>added, no caller yet"]
        G2["apikey.Generate + UserRBACSubject<br/>hardcoded to full access"]
        G1 --> Q2 --> G2
        G1 -.-> Q4
    end

    CODES --> G1
    G2 --> Q3

    Q3["InsertOAuth2ProviderAppToken<br/>gains a Scope param"]
    Q3 --> TOKENS[("oauth2_provider_app_tokens<br/>new column: scope text NULL")]

    subgraph refresh["POST /oauth2/token, grant_type=refresh_token"]
        G3["refreshTokenGrant"]
        Q5["GetOAuth2ProviderAppTokenByPrefix<br/>now returns Scope"]
        Q6["DeleteAPIKeyByIDReturningID<br/>added, no caller yet"]
        G3 --> Q5
        G3 -.-> Q6
    end

    TOKENS --> G3
    Q5 --> Q3

    subgraph enforce["Every authenticated API request"]
        E1["httpmw ExtractAPIKey"] --> E2["APIKey.ScopeSet()"] --> E3["UserRBACSubject"] --> E4["dbauthz authorize"]
    end

    TOKENS --> E1

    classDef changed fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#1b3c1e
    classDef dormant fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,stroke-dasharray:5 3,color:#1b3c1e
    class Q1,Q2,Q3,Q5,CODES,TOKENS changed
    class Q4,Q6 dormant
Loading

Solid green is added or changed here. Dashed green exists but has no caller yet. Everything else is unchanged, including the enforcement engine at the bottom, which already reads a key's scopes correctly and only needs real data fed into it.

How to review this PR

Most of the diff is generated. Suggested reading order:

  1. coderd/database/migrations/000567_oauth2_scope_enforcement.{up,down}.sql: the schema change. Additive, nullable, no backfill, with a COMMENT ON COLUMN on each recording that NULL means unrestricted.
  2. coderd/database/queries/oauth2.sql: scope added to both insert column lists, plus the new DeleteOAuth2ProviderAppCodeByIDReturningID :one alongside the untouched DeleteOAuth2ProviderAppCodeByID. The two Get...ByPrefix selects needed no edit, since they are SELECT * and return the generated model.
  3. coderd/database/queries/apikeys.sql: the same atomic delete for api_keys, alongside the untouched DeleteAPIKeyByID. Its 8 existing call sites in userauth.go, apikey.go, revoke.go, provisionerdserver.go, and tokens.go are unchanged.
  4. coderd/database/dbauthz/dbauthz.go: hand-written wrappers for the two new queries, each fetching via the existing Get...ByID, authorizing policy.ActionDelete against the fetched object, then delegating. The generic deleteQ helper does not fit, since it requires the delete to return only error.
  5. coderd/oauth2provider/authorize.go and coderd/oauth2provider/tokens.go: the only production changes, and behavior-neutral. exhaustruct requires the new field at every Insert...Params construction site, so these three pass an explicit empty sql.NullString{} until later phases negotiate a real value.
  6. coderd/database/dbgen/dbgen.go: threads Scope from the seed, matching the existing style for ResourceUri and Audience, so later phases can seed both a populated scope and a NULL one.
  7. coderd/database/dbauthz/dbauthz_test.go: a case per new query. MethodTestSuite seeds its accounting map from database.Store by reflection and fails with Method never called for anything untested, so a new query on the interface requires one.

coderd/database/{dump.sql,models.go,querier.go,queries.sql.go} and the dbmock/dbmetrics packages are fully generated by make gen; no need to review them directly.

Verified locally: make gen and make lint both clean (no enterprise/audit/table.go errors, confirming neither type needs to become auditable), the coderd/database/migrations suite passes both up and down, and dbauthz's TestMethodTestSuite passes.

Migration 000567 adds a nullable `scope text` to oauth2_provider_app_codes
and oauth2_provider_app_tokens so the scope negotiated at /oauth2/authorize
can travel from a code to the token it is exchanged for. No backfill, and
every insert writes NULL for now, which reads as unrestricted access, so
behavior is unchanged.

DeleteOAuth2ProviderAppCodeByIDReturningID and DeleteAPIKeyByIDReturningID
return sql.ErrNoRows when the row is already gone, letting the grant paths
enforce single use without a read-then-write race. The existing blind
deletes and their call sites are unchanged.

Refs PLAT-478
@linear-code

linear-code Bot commented Aug 10, 2026

Copy link
Copy Markdown

PLAT-470

@BobbyHo

BobbyHo commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-08-10 23:46 UTC by @BobbyHo

Review history
  • R1 (2026-08-11): 19 reviewers, 2 Nit, 3 Note, 6 P2, 3 P3, 1 P4, COMMENT. Review

deep-review v0.9.0 | Round 1 | 87fdd2b..7efa327

Last posted: Round 1, 15 findings (6 P2, 3 P3, 1 P4, 2 Nit, 3 Note), COMMENT. Review

Finding inventory

Finding inventory, PR #28007

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Open apikeys.sql:95 Single-use sql.ErrNoRows contract untested; api_keys variant SQL never runs R1 Bisky P2, Meruem P2, Netero Note (+Hisoka, Mafu-san, Mafuuu, Pariston, Knuckle, Knov, Ryosuke, Chopper, Kite P3) Yes
CRF-2 P2 Open 000567 up.sql:10 NULL-means-unrestricted is fail-open: zero value is most privileged state R1 Mafuuu P2, Pariston P2, Kurapika P3 (+Knov, Meruem P3, Zoro Note) Yes
CRF-3 P2 Open tokens.go:507 refreshTokenGrant writes NULL instead of carrying dbToken.Scope; permits widening R1 Knov P2, Razor P2, Chopper P3 Yes
CRF-4 P2 Open tokens.go:380 authorizationCodeGrant writes NULL instead of carrying dbCode.Scope R1 Razor P2, Knov P3, Chopper P3 Yes
CRF-5 P3 Open 000567 up.sql:12 Empty string is an undefined third state; add CHECK (scope <> '') R1 Knov P3, Ryosuke P3, Kite P3, Pariston P3, Knuckle P3, Hisoka Note Yes
CRF-6 P3 Open 000567 up.sql:16 Schema and Go comments state enforcement behavior no code implements, in present tense R1 Leorio P3, Mafu-san P3, Mafuuu P3, Razor Nit (+Gon, Knuckle, Kurapika, Ryosuke, Kite, Chopper Notes) Yes
CRF-7 P2 Open 000567 up.sql:1 8-line migration header mostly restates the COMMENT ON COLUMN statements R1 Gon P2 (Leorio praises the same header; contradiction flagged) Yes
CRF-8 P2 Open authorize.go:262 Three call-site comments duplicate the column doc's NULL semantics (also tokens.go:378, 505) R1 Gon P2 (Leorio proposes reword instead of delete) Yes
CRF-9 P3 Open dbauthz.go:2070 RETURNING * + fetchAndQuery collapses both hand-written wrappers to one line and returns the row phase-2 callers need R1 Robin P3, Zoro P3 Yes
CRF-10 P4 Open apikeys.sql:96 dbpurge cascade-deletes valid refresh tokens; future ErrNoRows conflates purge with reuse R1 Knuckle P4 Yes
CRF-11 Nit Open oauth2.sql:162 "already redeemed" over-specifies why the row is gone; match apikeys.sql phrasing R1 Razor Nit Yes
CRF-12 Nit Open 000567 up.sql:10 Migration name oauth2_scope_enforcement names the future feature, not this change R1 Gon Nit Yes
CRF-13 Note Open tokens.go:380 Token scope will live in two representations (api_keys.scopes enum array vs tokens.scope text) nothing forces to agree R1 Pariston Note Yes
CRF-14 Note Open modelmethods.go:288 expandRBACScope comment promises ScopeAll fallback; code returns an error (outside diff, phase-2 relevant) R1 Mafuuu Note Yes
CRF-15 Note Open oauth2.sql:161 Blind DeleteOAuth2ProviderAppCodeByID has one production caller; goes dead when phase 3 wires the returning variant R1 Zoro Note Yes
CRF-16 Note Dropped by orchestrator (round-trip today would test sqlc marshaling, not PR behavior; reviewer self-released) dbgen.go:1787 Scope threaded with no non-NULL writer or reader in tests R1 Bisky Note No
CRF-17 Note Dropped by orchestrator (folded into CRF-9; the proposed "deleteQ does not fit" comment would be inaccurate since fetchAndQuery fits after RETURNING *) dbauthz.go:2070 Rationale for hand-written wrapper invisible at the site R1 Gon Note No

Contested and acknowledged

None yet.

Round log

Round 1

Netero first pass: 1 Note (merged into CRF-1). Panel of 19 (17 trigger-matched + wildcards Meruem, Zoro). 6 P2, 3 P3, 1 P4, 2 Nit, 3 Note posted; 2 dropped. Convergence: single-use contract untested (11 reviewers), fail-open NULL encoding (6), scope not carried forward (3). Structural alternative preserved: RETURNING * + fetchAndQuery (Robin, Zoro). Contradiction flagged: Gon (header bloat) vs Leorio (header praise). Severity tiebreakers applied upward on CRF-1 (P2 over eight P3s) and CRF-4 (Razor P2 over Knov/Chopper P3; downgrade case was probability-based, consequence identical to CRF-3). Reviewed against 87fdd2b..7efa327.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@BobbyHo BobbyHo changed the title feat(coderd): add oauth2 scope columns and single-use delete queries feat: add oauth2 scope columns and single-use delete queries Aug 10, 2026

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is disciplined groundwork: additive nullable columns with metadata-only ALTERs, an exact-inverse down migration, NULL semantics recorded via COMMENT ON COLUMN so they survive into dump.sql and models.go, new queries added alongside the blind :exec deletes instead of repurposing them (all 8 DeleteAPIKeyByID call sites untouched), and follow-ups tracked in named Linear issues. The panel verified the central claims rather than trusting the description: Hisoka reproduced the concurrent DELETE ... RETURNING race against live Postgres (one winner, loser blocks on the row lock and gets zero rows at READ COMMITTED), Komugi traced the dbauthz Get-then-delete and confirmed the DELETE remains the sole arbiter so the TOCTOU is benign, and Razor/Kite confirmed the ON DELETE CASCADE from tokens to api_keys makes the key the correct single-use arbiter for refresh. Mafu-san audited every factual claim in the PR description against the tree and found it survives auditing. As Leorio put it about the query docs: "YES. This is how you document a query."

Findings: 6 P2, 3 P3, 1 P4, 2 Nit, 3 Note. No P0/P1, so this is a COMMENT review. The P2s cluster around three decisions that are cheapest to make now, while the columns have zero writers: (1) the single-use contract both new queries exist for is pinned by no test, and the api_keys variant's SQL never executes anywhere (CRF-1); (2) NULL-means-unrestricted makes the zero value the most privileged state, a fail-open encoding six reviewers flagged independently (CRF-2); (3) the two token-insert sites hardcode NULL instead of carrying the scope forward, contradicting the narrowing invariant this same PR writes into the schema, and the fix is behavior-neutral today (CRF-3, CRF-4). One structural alternative worth weighing before merge: switching the queries to RETURNING * lets both hand-written dbauthz wrappers collapse into the existing fetchAndQuery generic and hands phase-2 callers the deleted row instead of an ID they already had (CRF-9).

One genuine panel disagreement, flagged rather than resolved: Gon audited all 8 new comments and found four restate the NULL-semantics fact that lives at the COMMENT ON COLUMN definition (CRF-7, CRF-8), while Leorio praised the same writing as the standard the codebase should follow. Both agree on one thing: the present-tense enforcement claims describe code that does not exist yet (CRF-6). Whether the duplicates are deleted or reworded is the author's call; leaving them verbatim is the one option both reviewers reject.

Process notes: the failing "Pixel / Review" CI check appears to be an external app check rather than a build or test failure (no Pixel workflow exists in .github/workflows); worth a human confirming it is not actionable. CRF-14 sits outside this diff but directly under phase 2's feet: the expandRBACScope doc comment promises a ScopeAll fallback the code does not have.


coderd/database/modelmethods.go:288

Note [CRF-14] Adjacent to this PR's domain: the comment on expandRBACScope promises it "defaults to rbac.ScopeAll for backward compatibility" when the list is empty, but the code returns xerrors.New("no scopes provided"). (Mafuuu)

Outside this diff, but it is the enforcement engine the PR description says only needs real data fed into it, and its documented fallback contradicts its behavior. Whoever wires phase 2 will read that comment.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/queries/apikeys.sql Outdated
Comment thread coderd/database/migrations/000569_oauth2_scope_columns.up.sql
Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/database/migrations/000569_oauth2_scope_columns.up.sql
Comment thread coderd/database/queries/apikeys.sql
Comment thread coderd/database/queries/oauth2.sql Outdated
Comment thread coderd/database/migrations/000569_oauth2_scope_columns.up.sql
Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/database/queries/oauth2.sql Outdated
BobbyHo and others added 6 commits August 11, 2026 08:33
Both scope columns were nullable with NULL meaning "unrestricted", which
made the most privileged state the one a forgotten field produces:
sql.NullString{} is NULL is full access, and exhaustruct is satisfied by
exactly that literal. An audit of either table could not separate a
deliberate legacy grant from a mint path that dropped the scope.

Backfill both columns to coder:all, which records what existing rows
already have in fact since apikey.Generate defaults minted OAuth2 keys to
that scope, then apply NOT NULL and CHECK (scope <> ''). NOT NULL alone
would not be enough: sqlc maps text NOT NULL to a Go string whose zero
value inserts cleanly, so the fail-closed property needs both clauses. No
DEFAULT survives, or an INSERT omitting the column would silently receive
an unrestricted grant. Matches the encoding api_keys.scopes and
workspace_agents.api_key_scope already use, and follows migration 000389's
backfill-then-constrain shape.

The two grant paths now carry the parent's scope forward
(Scope: dbCode.Scope, Scope: dbToken.Scope) instead of hardcoding an empty
value, which is RFC 6749 section 6's default and removes the phase-ordering
hazard where a scoped token could refresh into an unrestricted one.
ProcessAuthorize writes the sentinel, since persisting a requested scope
before validation exists would store unvalidated client input.

Refs PLAT-478

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…etes

Both single-use deletes returned a bare id, which forced a hand-written
dbauthz wrapper each. Returning the whole row lets them collapse into the
existing fetchAndQuery generic, since that helper unifies its fetch and
query on one rbac.Objecter and a bare id satisfies no such interface. Each
10-line wrapper becomes a single call, and a caller now reads the deleted
row's state, including a code's negotiated scope, from the same atomic
delete rather than trusting an earlier unauthorized read. Renamed to
...ByIDReturningRow, since ...ReturningID no longer describes them.

Add TestSingleUseDeleteByIDReturningRow, which pins the contract both
queries exist for: the first delete returns the row, a second returns
sql.ErrNoRows. Neither query previously executed against a real database on
its already-gone path, so converting one back to :exec or adding a soft
delete would have broken single use with CI still green. The concurrent
exactly-one-winner half is deliberately not covered here; it exercises
Postgres row-lock semantics rather than this code.

Rename migration 000567 to oauth2_scope_columns. It adds columns and
constraints; enforcement lands in a later phase, and migration names freeze
at merge.

Refs PLAT-478

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…000569

origin/main merged 000567_chat_file_purge_indexes and
000568_service_account_notifications after this branch's point. CI validates
the PR merge, where two files numbered 000567 coexisted and the migrate iofs
driver panicked with "duplicate migration file", taking down gen, lint,
sqlc-vet and every test-go-pg job. Git reports the merge as MERGEABLE because
the two are different filenames; the collision is on the version number,
which git cannot see.

Renumbered with ./coderd/database/migrations/fix_migration_numbers.sh.

Refs PLAT-478

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two test sites built InsertOAuth2ProviderAppCodeParams and
InsertOAuth2ProviderAppTokenParams without Scope, so after the columns became
NOT NULL with CHECK (scope <> '') they inserted an empty string and tripped the
constraint. Broke TestOAuth2ProviderTokenExchange/ExpiredCode and every
TestOAuth2ProviderTokenRefresh subtest on the Linux postgres jobs.

exhaustruct is disabled for _test.go (.golangci.yaml:222), so nothing forces
the field in tests and the constraint is the only backstop. Audited every
remaining InsertOAuth2ProviderApp{Code,Token}Params literal in the tree; these
two were the only omissions, and no raw SQL inserts bypass sqlc.

Refs PLAT-478

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BobbyHo
BobbyHo marked this pull request as ready for review August 11, 2026 19:00
@BobbyHo
BobbyHo requested a review from Emyrk August 11, 2026 19:00
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