mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
OAuth2 tokens issued by Coder ignore scope entirely. The authorize endpoint parses the `scope` parameter and then discards it, and both grant paths mint API keys with 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 carries one from the authorize step to the token it produces. Schema and query groundwork for that pipeline. No behavior change on its own. - Migration `000569` adds a `scope` column 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. - Existing rows are backfilled to `coder:all`, then both columns become NOT NULL with a non-empty CHECK. Every OAuth2 key is unrestricted in fact today, so the backfill only writes that down, and a caller that omits the column now fails instead of silently issuing full access. - Adds `DeleteOAuth2ProviderAppCodeByIDReturningRow` and `DeleteAPIKeyByIDReturningRow`, 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. - No callers yet. The existing blind deletes and all of their call sites are untouched, and codes and tokens record `coder:all` until a later phase negotiates a real value. Phase 1 of [PLAT-470](https://linear.app/codercom/issue/PLAT-470), tracked as [PLAT-478](https://linear.app/codercom/issue/PLAT-478/phase-1-schema-and-queries). Scope validation at authorize, applying the negotiated scope in the code grant, and refresh narrowing follow as separate PRs. Verified locally: `make gen` and `make lint` clean, the migrations suite passes both up and down, and dbauthz's `TestMethodTestSuite` passes. <details> <summary>End-to-end scope enforcement flow (green marks what this PR touches)</summary> ```mermaid 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 NOT NULL")] subgraph codegrant["POST /oauth2/token, grant_type=authorization_code"] G1["authorizationCodeGrant"] Q2["GetOAuth2ProviderAppCodeByPrefix<br/>now returns Scope"] Q4["DeleteOAuth2ProviderAppCodeByIDReturningRow<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 NOT NULL")] subgraph refresh["POST /oauth2/token, grant_type=refresh_token"] G3["refreshTokenGrant"] Q5["GetOAuth2ProviderAppTokenByPrefix<br/>now returns Scope"] Q6["DeleteAPIKeyByIDReturningRow<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 ``` 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. </details> <details> <summary>Suggested reading order</summary> Most of the diff is generated. `dump.sql`, `models.go`, `querier.go`, `queries.sql.go`, `check_constraint.go`, and the dbmock and dbmetrics packages all come from `make gen`. 1. `migrations/000569_oauth2_scope_columns.{up,down}.sql`: additive column, backfill, NOT NULL, CHECK, and a `COMMENT ON COLUMN` on each. 2. `queries/oauth2.sql` and `queries/apikeys.sql`: `scope` added to both insert column lists, plus the two new returning-row deletes alongside the untouched originals. The `Get...ByPrefix` selects needed no edit, since they are `SELECT *`. 3. `dbauthz/dbauthz.go`: hand-written wrappers for the two new queries, each fetching by ID, authorizing delete against the fetched object, then delegating. The generic `deleteQ` helper does not fit, since it requires the delete to return only `error`. 4. `oauth2provider/authorize.go` and `oauth2provider/tokens.go`: the only production changes, all behavior-neutral. 5. `dbgen/dbgen.go` and `dbauthz/dbauthz_test.go`: seed threading, plus a case per new query. `MethodTestSuite` fails with "Method never called" for anything untested. Neither type needs to become auditable, which `make lint` confirms by not erroring on `enterprise/audit/table.go`. </details> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
254 lines
5.5 KiB
SQL
254 lines
5.5 KiB
SQL
-- name: GetOAuth2ProviderApps :many
|
|
SELECT * FROM oauth2_provider_apps ORDER BY (name, id) ASC;
|
|
|
|
-- name: GetOAuth2ProviderAppByID :one
|
|
SELECT * FROM oauth2_provider_apps WHERE id = $1;
|
|
|
|
-- name: InsertOAuth2ProviderApp :one
|
|
INSERT INTO oauth2_provider_apps (
|
|
id,
|
|
created_at,
|
|
updated_at,
|
|
name,
|
|
icon,
|
|
callback_url,
|
|
redirect_uris,
|
|
client_type,
|
|
dynamically_registered,
|
|
client_id_issued_at,
|
|
client_secret_expires_at,
|
|
grant_types,
|
|
response_types,
|
|
token_endpoint_auth_method,
|
|
scope,
|
|
contacts,
|
|
client_uri,
|
|
logo_uri,
|
|
tos_uri,
|
|
policy_uri,
|
|
jwks_uri,
|
|
jwks,
|
|
software_id,
|
|
software_version,
|
|
registration_access_token,
|
|
registration_client_uri
|
|
) VALUES(
|
|
$1,
|
|
$2,
|
|
$3,
|
|
$4,
|
|
$5,
|
|
$6,
|
|
$7,
|
|
$8,
|
|
$9,
|
|
$10,
|
|
$11,
|
|
$12,
|
|
$13,
|
|
$14,
|
|
$15,
|
|
$16,
|
|
$17,
|
|
$18,
|
|
$19,
|
|
$20,
|
|
$21,
|
|
$22,
|
|
$23,
|
|
$24,
|
|
$25,
|
|
$26
|
|
) RETURNING *;
|
|
|
|
-- name: UpdateOAuth2ProviderAppByID :one
|
|
UPDATE oauth2_provider_apps SET
|
|
updated_at = $2,
|
|
name = $3,
|
|
icon = $4,
|
|
callback_url = $5,
|
|
redirect_uris = $6,
|
|
client_type = $7,
|
|
dynamically_registered = $8,
|
|
client_secret_expires_at = $9,
|
|
grant_types = $10,
|
|
response_types = $11,
|
|
token_endpoint_auth_method = $12,
|
|
scope = $13,
|
|
contacts = $14,
|
|
client_uri = $15,
|
|
logo_uri = $16,
|
|
tos_uri = $17,
|
|
policy_uri = $18,
|
|
jwks_uri = $19,
|
|
jwks = $20,
|
|
software_id = $21,
|
|
software_version = $22
|
|
WHERE id = $1 RETURNING *;
|
|
|
|
-- name: DeleteOAuth2ProviderAppByID :exec
|
|
DELETE FROM oauth2_provider_apps WHERE id = $1;
|
|
|
|
-- name: GetOAuth2ProviderAppSecretByID :one
|
|
SELECT * FROM oauth2_provider_app_secrets WHERE id = $1;
|
|
|
|
-- name: GetOAuth2ProviderAppSecretsByAppID :many
|
|
SELECT * FROM oauth2_provider_app_secrets WHERE app_id = $1 ORDER BY (created_at, id) ASC;
|
|
|
|
-- name: GetOAuth2ProviderAppSecretByPrefix :one
|
|
SELECT * FROM oauth2_provider_app_secrets WHERE secret_prefix = $1;
|
|
|
|
-- name: InsertOAuth2ProviderAppSecret :one
|
|
INSERT INTO oauth2_provider_app_secrets (
|
|
id,
|
|
created_at,
|
|
secret_prefix,
|
|
hashed_secret,
|
|
display_secret,
|
|
app_id
|
|
) VALUES(
|
|
$1,
|
|
$2,
|
|
$3,
|
|
$4,
|
|
$5,
|
|
$6
|
|
) RETURNING *;
|
|
|
|
-- name: DeleteOAuth2ProviderAppSecretByID :exec
|
|
DELETE FROM oauth2_provider_app_secrets WHERE id = $1;
|
|
|
|
-- name: GetOAuth2ProviderAppCodeByID :one
|
|
SELECT * FROM oauth2_provider_app_codes WHERE id = $1;
|
|
|
|
-- name: GetOAuth2ProviderAppCodeByPrefix :one
|
|
SELECT * FROM oauth2_provider_app_codes WHERE secret_prefix = $1;
|
|
|
|
-- name: InsertOAuth2ProviderAppCode :one
|
|
INSERT INTO oauth2_provider_app_codes (
|
|
id,
|
|
created_at,
|
|
expires_at,
|
|
secret_prefix,
|
|
hashed_secret,
|
|
app_id,
|
|
user_id,
|
|
resource_uri,
|
|
code_challenge,
|
|
code_challenge_method,
|
|
state_hash,
|
|
redirect_uri,
|
|
scope
|
|
) VALUES(
|
|
$1,
|
|
$2,
|
|
$3,
|
|
$4,
|
|
$5,
|
|
$6,
|
|
$7,
|
|
$8,
|
|
$9,
|
|
$10,
|
|
$11,
|
|
$12,
|
|
$13
|
|
) RETURNING *;
|
|
|
|
-- name: DeleteOAuth2ProviderAppCodeByID :exec
|
|
DELETE FROM oauth2_provider_app_codes WHERE id = $1;
|
|
|
|
-- name: DeleteOAuth2ProviderAppCodesByAppAndUserID :exec
|
|
DELETE FROM oauth2_provider_app_codes WHERE app_id = $1 AND user_id = $2;
|
|
|
|
-- name: InsertOAuth2ProviderAppToken :one
|
|
INSERT INTO oauth2_provider_app_tokens (
|
|
id,
|
|
created_at,
|
|
expires_at,
|
|
hash_prefix,
|
|
refresh_hash,
|
|
app_id,
|
|
app_secret_id,
|
|
api_key_id,
|
|
user_id,
|
|
audience,
|
|
scope
|
|
) VALUES(
|
|
$1,
|
|
$2,
|
|
$3,
|
|
$4,
|
|
$5,
|
|
$6,
|
|
$7,
|
|
$8,
|
|
$9,
|
|
$10,
|
|
$11
|
|
) RETURNING *;
|
|
|
|
-- name: GetOAuth2ProviderAppTokenByPrefix :one
|
|
SELECT * FROM oauth2_provider_app_tokens WHERE hash_prefix = $1;
|
|
|
|
-- name: GetOAuth2ProviderAppTokenByAPIKeyID :one
|
|
SELECT * FROM oauth2_provider_app_tokens WHERE api_key_id = $1;
|
|
|
|
-- name: GetOAuth2ProviderAppsByUserID :many
|
|
-- Joins directly on oauth2_provider_app_tokens.app_id rather than through
|
|
-- app_secret_id, since app_secret_id is NULL for public (secretless) clients
|
|
-- and would silently exclude their tokens from this listing.
|
|
SELECT
|
|
COUNT(DISTINCT oauth2_provider_app_tokens.id) as token_count,
|
|
sqlc.embed(oauth2_provider_apps)
|
|
FROM oauth2_provider_app_tokens
|
|
INNER JOIN oauth2_provider_apps
|
|
ON oauth2_provider_apps.id = oauth2_provider_app_tokens.app_id
|
|
WHERE
|
|
oauth2_provider_app_tokens.user_id = $1
|
|
GROUP BY
|
|
oauth2_provider_apps.id;
|
|
|
|
-- name: DeleteOAuth2ProviderAppTokensByAppAndUserID :exec
|
|
-- Filters directly on app_id rather than joining through app_secret_id,
|
|
-- since app_secret_id is NULL for public (secretless) clients and would
|
|
-- silently exclude their tokens from this delete.
|
|
DELETE FROM
|
|
oauth2_provider_app_tokens
|
|
WHERE
|
|
oauth2_provider_app_tokens.app_id = $1
|
|
AND oauth2_provider_app_tokens.user_id = $2;
|
|
|
|
-- RFC 7591/7592 Dynamic Client Registration queries
|
|
|
|
-- name: GetOAuth2ProviderAppByClientID :one
|
|
SELECT * FROM oauth2_provider_apps WHERE id = $1;
|
|
|
|
-- name: UpdateOAuth2ProviderAppByClientID :one
|
|
UPDATE oauth2_provider_apps SET
|
|
updated_at = $2,
|
|
name = $3,
|
|
icon = $4,
|
|
callback_url = $5,
|
|
redirect_uris = $6,
|
|
client_type = $7,
|
|
client_secret_expires_at = $8,
|
|
grant_types = $9,
|
|
response_types = $10,
|
|
token_endpoint_auth_method = $11,
|
|
scope = $12,
|
|
contacts = $13,
|
|
client_uri = $14,
|
|
logo_uri = $15,
|
|
tos_uri = $16,
|
|
policy_uri = $17,
|
|
jwks_uri = $18,
|
|
jwks = $19,
|
|
software_id = $20,
|
|
software_version = $21
|
|
WHERE id = $1 RETURNING *;
|
|
|
|
-- name: DeleteOAuth2ProviderAppByClientID :exec
|
|
DELETE FROM oauth2_provider_apps WHERE id = $1;
|
|
|