feat!: add admin-controlled dynamic client registration toggle by BobbyHo · Pull Request #27316 · coder/coder · GitHub
Skip to content

feat!: add admin-controlled dynamic client registration toggle - #27316

Merged
BobbyHo merged 33 commits into
mainfrom
coder-eng-3056-dcr-flag
Jul 28, 2026
Merged

feat!: add admin-controlled dynamic client registration toggle#27316
BobbyHo merged 33 commits into
mainfrom
coder-eng-3056-dcr-flag

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

POST /oauth2/register (RFC 7591 Dynamic Client Registration) has exactly one gate today: ExperimentOAuth2, a static, process-lifetime flag that wraps the entire /oauth2/* route tree as an all-or-nothing switch. That flag is scheduled for removal at GA, which would leave DCR with zero admin control at all once it is gone.

Add a persistent, DCR-specific oauth2_dcr_enabled deployment setting, independent of the experiment system, so admin control over DCR survives GA. POST /oauth2/register checks the flag and rejects new registrations with an RFC 7591-shaped 403 when disabled; discovery metadata (GET /.well-known/oauth-authorization-server) conditionally omits registration_endpoint. A new audited GET/PUT /api/v2/oauth2-provider/settings endpoint lets an owner toggle it live, no restart required. The setting defaults to disabled, matching the canonical design proposal; disabling only stops new self-registrations, clients that already registered continue to authorize and exchange tokens normally.

Address issue described in ENG-3056.

Where this sits in the request path

sequenceDiagram
    autonumber
    participant A as Admin
    participant S as coderd
    participant DB as site_configs<br/>(oauth2_dcr_enabled)
    participant C as OAuth2/MCP Client

    Note over A,S: Admin toggles DCR (new)
    A->>S: PUT /api/v2/oauth2-provider/settings<br/>{dynamic_client_registration_enabled: false}
    S->>S: authorizeContext(ActionUpdate, ResourceDeploymentConfig)
    S->>DB: UPSERT oauth2_dcr_enabled = false
    S-->>A: 200 OK (audited)

    Note over C,S: Client discovery + registration afterward
    C->>S: GET /.well-known/oauth-authorization-server
    S->>DB: GetOAuth2DCREnabled (system ctx, every request, no cache)
    DB-->>S: false
    S-->>C: 200 metadata, registration_endpoint omitted

    C->>S: POST /oauth2/register
    S->>DB: GetOAuth2DCREnabled (system ctx, every request, no cache)
    DB-->>S: false
    S-->>C: 403 invalid_request,<br/>"Dynamic client registration is disabled"

    Note over C,S: A client that registered before the change is unaffected
    C->>S: GET /oauth2/authorize?client_id=...
    Note over S: no DCR-enabled check on this path
    S-->>C: 200 (proceeds normally)

    C->>S: PUT/DELETE /oauth2/clients/{client_id} (RFC 7592 self-management)
    Note over S: no DCR-enabled check on this path either
    S-->>C: 200 (proceeds normally)
Loading

Files changed: manual vs. generated

Reviewers should focus on the manual files. The generated ones are make gen output that follows mechanically from the manual changes and don't need direct review.

Manual files (26) — click to expand, grouped the same way as "Suggested review order" below

1. Database

File What changed
coderd/database/queries/siteconfig.sql New GetOAuth2DCREnabled/UpsertOAuth2DCREnabled query pair on the existing generic site_configs table. No schema change.
coderd/database/dbauthz/dbauthz.go RBAC check (rbac.ResourceDeploymentConfig) on the two new query methods; extends the subjectSystemOAuth2 system-actor role with read-only ResourceDeploymentConfig access, needed so the public discovery/registration endpoints can read the flag via dbauthz.AsSystemOAuth2.
coderd/database/dbauthz/dbauthz_test.go RBAC assertion coverage for GetOAuth2DCREnabled/UpsertOAuth2DCREnabled in the method-coverage test suite.

2. Request gating (the actual feature)

File What changed
coderd/oauth2provider/registration.go The actual gate: CreateDynamicClientRegistration reads the flag first and returns an RFC 7591-shaped 403 when disabled (defaults disabled if never configured).
coderd/oauth2provider/registration_test.go New unit test, TestCreateDynamicClientRegistration_DCREnabled: calls the handler directly (no HTTP server), covering enabled / explicitly disabled / never-configured.
coderd/oauth2provider/metadata.go GetAuthorizationServerMetadata conditionally omits registration_endpoint from discovery metadata when DCR is disabled.
coderd/oauth2provider/metadata_test.go New unit test, TestGetAuthorizationServerMetadata_DCREnabled: same three states, for the discovery handler.

3. Admin settings endpoint

File What changed
codersdk/oauth2.go New OAuth2ProviderSettings SDK type plus Client.OAuth2ProviderSettings/PutOAuth2ProviderSettings methods.
coderd/oauth2.go New oauth2ProviderSettings/putOAuth2ProviderSettings admin handlers (audited via audit.InitRequest); updates the GetAuthorizationServerMetadata call site to pass api.Database.
coderd/coderd.go Registers GET/PUT /api/v2/oauth2-provider/settings.
coderd/oauth2_provider_settings_test.go New test file: admin GET/PUT round-trip, default-disabled-before-any-PUT, and 403 for a non-owner on both GET and PUT.

4. Audit wiring

File What changed
coderd/database/types.go New database.OAuth2ProviderSettings audit-only struct (mirrors NotificationsSettings).
coderd/audit/diff.go Adds the new struct to the Auditable type union.
coderd/audit/request.go Adds the new struct to all four dispatch switches (ResourceTarget, ResourceID, ResourceType, ResourceRequiresOrgID).
codersdk/audit.go New API-facing ResourceTypeOAuth2ProviderSettings constant and its FriendlyString case.
enterprise/audit/table.go Field-level audit action map (ActionTrack/ActionIgnore) for the new struct.
coderd/database/migrations/000546_audit_oauth2_provider_settings.up.sql Adds oauth2_provider_settings to the resource_type Postgres enum, required for the audit wiring above (resource_type is a real enum, not a Go-only value).
coderd/database/migrations/000546_audit_oauth2_provider_settings.down.sql No-op (ALTER TYPE ... ADD VALUE can't be reverted).

5. Test-suite ripple from the disabled-by-default flip

File What changed
coderd/oauth2provider/oauth2providertest/helpers.go New shared test helper, EnableDCR, since DCR now defaults to disabled and many pre-existing tests need it turned on to register a client.
coderd/oauth2_test.go Adds TestOAuth2DynamicClientRegistrationDisabled (registers a client, disables DCR, verifies new registration is rejected while the existing client's self-management, authorize, and token exchange all keep working); calls EnableDCR in every pre-existing test that registers a client.
coderd/oauth2_error_compliance_test.go Calls EnableDCR in every test that registers a client, so RFC-error-format assertions aren't masked by the new disabled-by-default gate.
coderd/oauth2_metadata_validation_test.go Same: EnableDCR added to every registration-dependent test.
coderd/oauth2_security_test.go Same.
coderd/oauth2provider/validation_test.go Same (near-duplicate of oauth2_metadata_validation_test.go in a different package).
coderd/oauth2provider/provider_test.go Same.
coderd/mcp/mcp_e2e_test.go Same, for the MCP end-to-end dynamic-registration flow test.
Generated files (12) — from make gen, no need to review directly

coderd/apidoc/docs.go, coderd/apidoc/swagger.json, coderd/database/dbmetrics/querymetrics.go, coderd/database/dbmock/dbmock.go, coderd/database/dump.sql, coderd/database/models.go, coderd/database/querier.go, coderd/database/queries.sql.go, docs/admin/security/audit-logs.md, docs/reference/api/enterprise.md, docs/reference/api/schemas.md, site/src/api/typesGenerated.ts.

Suggested review order

1. Database

Establishes the persisted setting and its RBAC rule; everything else builds on GetOAuth2DCREnabled/UpsertOAuth2DCREnabled.

  1. coderd/database/queries/siteconfig.sql — the two new queries. Same boolean-encoding pattern as the existing oauth2_github_default_eligible key right above them in the same file.
  2. coderd/database/dbauthz/dbauthz.go — the RBAC wrapper for those two queries, plus the subjectSystemOAuth2 role extension (search this file for ResourceDeploymentConfig, it appears in both spots).
  3. coderd/database/dbauthz/dbauthz_test.go — asserts the RBAC checks from (2) actually fire.

2. Request gating (the actual feature)

Where POST /oauth2/register and discovery metadata change behavior.

  1. coderd/oauth2provider/registration.go — the primary gate. Read this first; it's the feature.
  2. coderd/oauth2provider/registration_test.go — its new unit test, exercising the gate's three states directly against the handler.
  3. coderd/oauth2provider/metadata.go — the same gating pattern applied to the discovery GET endpoint.
  4. coderd/oauth2provider/metadata_test.go — its new unit test.

3. Admin settings endpoint

How an owner flips the setting live.

  1. codersdk/oauth2.go — the OAuth2ProviderSettings SDK type and Client methods first; this is the public contract everything below implements against.
  2. coderd/oauth2.go — the GET/PUT handlers themselves.
  3. coderd/coderd.go — route registration, to see where those handlers get wired in.
  4. coderd/oauth2_provider_settings_test.go — round-trip and permission tests.

4. Audit wiring

Plumbing required so step 3's PUT is auditable; mechanical except for (3).

  1. coderd/database/types.go — the audit-only struct; everything else in this layer exists to plumb it through.
  2. coderd/audit/diff.go — adds it to the Auditable type union (the compiler enforces this one).
  3. coderd/audit/request.go — the four dispatch switches; the one part of this layer worth reading closely.
  4. codersdk/audit.go — the API-facing resource type constant.
  5. enterprise/audit/table.go — the field-action map.
  6. coderd/database/migrations/000546_audit_oauth2_provider_settings.{up,down}.sql — read last; a consequence of needing a new resource_type enum value for (1)-(5), not a design decision of its own.

5. Test-suite ripple from the disabled-by-default flip

  1. coderd/oauth2provider/oauth2providertest/helpers.go — the new EnableDCR helper. Read first to understand the fix pattern before seeing it applied repeatedly.
  2. coderd/oauth2_test.go — next, since it also contains the new TestOAuth2DynamicClientRegistrationDisabled, not just EnableDCR call sites.
  3. The rest, in any order, they're mechanical repeats of the same one-line addition: coderd/oauth2_error_compliance_test.go, coderd/oauth2_metadata_validation_test.go, coderd/oauth2_security_test.go, coderd/oauth2provider/validation_test.go, coderd/oauth2provider/provider_test.go, coderd/mcp/mcp_e2e_test.go.

Explicitly out of scope

Per the design proposal: rate limiting on POST /oauth2/register (tracked separately), retroactively affecting already-registered clients when DCR is disabled (this only gates new self-registration), and an Initial Access Token requirement (a separate, follow-up ticket).

BobbyHo added 3 commits July 16, 2026 16:13
Adds GetOAuth2DCREnabled/UpsertOAuth2DCREnabled queries backed by a new
site_configs key, with dbauthz RBAC checks against
rbac.ResourceDeploymentConfig. This will back an admin-toggleable,
persistent replacement for the ExperimentOAuth2 flag currently gating
dynamic client registration.
Adds a DCR-specific, persistent enabled/disabled setting so admin control
over dynamic client registration survives the eventual removal of the
ExperimentOAuth2 flag. POST /oauth2/register now checks the flag and the
discovery endpoint conditionally omits registration_endpoint, both
defaulting to enabled to preserve current behavior on upgrade. Adds an
audited GET/PUT /api/v2/oauth2-provider/settings admin endpoint backed by
a new oauth2_provider_settings audit resource type.

Disabling only stops new self-registrations; clients registered before the
toggle continue to authorize and exchange tokens normally.
Aligns with the canonical design proposal, which specifies DCR disabled
by default, correcting this repos local proposal doc which argued for
default-enabled. Safe to change now since OAuth2 as a whole is still
gated behind ExperimentOAuth2 and has not reached GA.

Adds oauth2providertest.EnableDCR and updates every pre-existing OAuth2
test that registers a client to call it explicitly, since those tests
previously relied on the implicit default-enabled behavior.
@linear-code

linear-code Bot commented Jul 17, 2026

Copy link
Copy Markdown

ENG-3056

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

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 added 13 commits July 17, 2026 11:46
paralleltestctx flags reusing a parent-scope testutil.Context after
t.Parallel(), since the timeout clock starts before the parallel subtest
actually runs. Give each of the three affected subtests in
TestOAuth2DynamicClientRegistrationDisabled its own context instead.
TestEndpointsDocumented enforces @id == slugified @summary. The PUT
handler's @id was put-oauth2-provider-settings but its @summary is
"Update OAuth2 provider settings", which slugifies to
update-oauth2-provider-settings. Regenerated swagger docs to match.
TestCreateDynamicClientRegistration_DCREnabled calls
CreateDynamicClientRegistration directly via httptest.NewRecorder,
bypassing the full coderdtest HTTP server, exercising all three states:
explicitly enabled, explicitly disabled, and never configured
(defaults to disabled). Complements the existing HTTP-level integration
coverage in TestOAuth2DynamicClientRegistrationDisabled.
"Never configured means DCR defaults to disabled" was awkward, elliptical
phrasing. Reword to a plain if/then statement in all three call sites.
Document the deliberate no-cache decision at both per-request read
sites: registration and discovery aren't hot paths, so a DB read per
request is an acceptable cost for a setting that can be flipped live.
A flood of requests from a misconfigured or misbehaving client is a
rate-limiting/firewalling problem, not a reason to add a cache here.
TestGetAuthorizationServerMetadata_DCREnabled calls
GetAuthorizationServerMetadata directly via httptest.NewRecorder,
bypassing the full coderdtest HTTP server, exercising all three states:
explicitly enabled, explicitly disabled, and never configured
(defaults to omitting registration_endpoint).
GetPermissionDenied and PutPermissionDenied were identical except for
which SDK method they called against a forbidden client. Collapse them
into a single table-driven PermissionDenied subtest.
Adds coder oauth2-provider dcr enable/disable, mirroring the existing
coder notifications pause/resume and coder prebuilds pause/resume
CLI pattern for toggling a boolean deployment setting. Wraps
PutOAuth2ProviderSettings. Nested under a dcr subgroup (rather than
flat like its two precedents) since OAuth2ProviderSettings is expected
to gain a second field (Initial Access Token requirement) later.
main gained 000546_drop_chat_history_api_key_fks and
000547_mcp_server_oauth2_revocation_url since this branch was created,
colliding with this branch's own 000546_audit_oauth2_provider_settings.
Renumbered to 000548 via fix_migration_numbers.sh.
putOAuth2ProviderSettings only ever set aReq.New, leaving aReq.Old at
its zero value. Diffing against the zero value happened to look
correct when enabling DCR (false -> true) but produced an empty diff
when disabling it (true -> false), silently hiding that the setting
had changed. Fetches the real previous value via GetOAuth2DCREnabled
before the upsert, same never-configured and RBAC-error handling as
the GET handler.

Adds TestOAuth2ProviderSettingsAuditDiff (enterprise/coderd), since the
mock auditor used elsewhere always stubs diffs to empty and can't
catch this, and a migration test reproducing the production upgrade
path for the audit_logs resource_type enum addition.
main independently added 000548_drop_chat_gateway_key_columns since
this branch's last sync, colliding with this branch's own
000548_audit_oauth2_provider_settings. Renumbered to 000549 via
fix_migration_numbers.sh and updated the matching test name/comments.
@BobbyHo

BobbyHo commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Manual verification: OAuth2 Dynamic Client Registration admin toggle

Ran the flows below against a local dev Coderd (./scripts/develop.sh) to confirm the behavior end-to-end, on top of the automated test suite. Each section shows the flow being exercised, then the actual request/response captured while testing (collapsed below each diagram).

Summary

# Test Result
1 Default state: DCR disabled out of the box ✅ Pass
2 Non-owner member denied read/write ✅ Pass
3 Owner enables DCR live, no restart ✅ Pass
3a Same via CLI (coder oauth2-provider dcr enable/disable) ✅ Pass
3b Non-owner member gets the same 403 via the CLI ✅ Pass
4 Once enabled: anonymous client discovers + registers ✅ Pass
5 Disable again: new registrations rejected, existing client unaffected ✅ Pass
6 Cleanup ✅ Pass
7a Debug-log visibility for a DCR-disabled rejection N/A — documented, not a pass/fail check
7b Audit log records the setting change (old/new diff) ✅ Pass (found and fixed a real bug during testing)
7c coder support bundle does not surface DCR state N/A — ruled out explicitly
8 Upgrade path: existing deployment → this branch ✅ Pass

0. Prerequisites

Setup commands
./scripts/develop.sh   # Terminal 1, leave running

./scripts/coder-dev.sh login   # Terminal 2
export SESSION_TOKEN=$(cat ./.coderv2/session)
export BASE_URL=http://localhost:3000
export AUTH_HEADER="Coder-Session-Token: $SESSION_TOKEN"

export ORG_ID=$(curl -s "$BASE_URL/api/v2/users/me" -H "$AUTH_HEADER" | jq -r '.organization_ids[0]')
curl -s -X POST "$BASE_URL/api/v2/users" -H "$AUTH_HEADER" \
  -H "Content-Type: application/json" \
  -d '{"email": "dcr-member@coder.com", "username": "dcr-member", "password": "SomeSecurePassword123!", "login_type": "password", "organization_ids": ["'"$ORG_ID"'"]}'

export MEMBER_SESSION_TOKEN=$(curl -s -X POST "$BASE_URL/api/v2/users/login" \
  -H "Content-Type: application/json" \
  -d '{"email": "dcr-member@coder.com", "password": "SomeSecurePassword123!"}' | jq -r '.session_token')
export MEMBER_AUTH_HEADER="Coder-Session-Token: $MEMBER_SESSION_TOKEN"

1. Default state: DCR is disabled out of the box

Verifies coderd/oauth2provider/{metadata,registration}.go's "never configured" branch and coderd/oauth2.go's oauth2ProviderSettings GET handler.

sequenceDiagram
    participant A as Owner
    participant C as Anonymous OAuth2 Client
    participant S as coderd

    A->>S: GET /api/v2/oauth2-provider/settings
    S-->>A: 200 OK - dynamic_client_registration_enabled: false
    Note over S: Never configured (sql.ErrNoRows) -> treated as disabled

    C->>S: GET /.well-known/oauth-authorization-server
    S-->>C: 200 OK - registration_endpoint absent from response

    C->>S: POST /oauth2/register
    S-->>C: 403 invalid_request,<br/>"Dynamic client registration is disabled on this deployment"
Loading
Commands and output
curl -s "$BASE_URL/api/v2/oauth2-provider/settings" -H "$AUTH_HEADER" | jq .
{
  "dynamic_client_registration_enabled": false
}
curl -s "$BASE_URL/.well-known/oauth-authorization-server" | jq 'has("registration_endpoint")'
false
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/oauth2/register" \
  -H "Content-Type: application/json" \
  -d '{"client_name": "manual-test-dcr-disabled", "redirect_uris": ["http://localhost:9876/callback"]}'
{"error":"invalid_request","error_description":"Dynamic client registration is disabled on this deployment"}
HTTP 403

✅ Pass — settings default to false, discovery omits registration_endpoint, and registration is rejected with the RFC 7591-shaped error.


2. A non-owner member can't view or change the setting

Verifies the RBAC check in coderd/database/dbauthz/dbauthz.go on GetOAuth2DCREnabled/UpsertOAuth2DCREnabledrbac.ResourceDeploymentConfig requires a role (like Owner) that has it; the default "member" role does not.

sequenceDiagram
    participant M as Member (non-owner)
    participant S as coderd

    M->>S: GET /api/v2/oauth2-provider/settings
    S-->>M: 403 Forbidden
    M->>S: PUT /api/v2/oauth2-provider/settings
    S-->>M: 403 Forbidden
Loading
Commands and output
curl -s -o /dev/null -w "%{http_code}\n" "$BASE_URL/api/v2/oauth2-provider/settings" -H "$MEMBER_AUTH_HEADER"
403
curl -s -o /dev/null -w "%{http_code}\n" -X PUT "$BASE_URL/api/v2/oauth2-provider/settings" \
  -H "$MEMBER_AUTH_HEADER" -H "Content-Type: application/json" \
  -d '{"dynamic_client_registration_enabled": true}'
403

✅ Pass — non-owner member correctly denied both GET and PUT.


3. Owner enables DCR live (no restart required)

Verifies putOAuth2ProviderSettings (coderd/oauth2.go) persists the change and it's readable back immediately.

sequenceDiagram
    participant A as Owner
    participant S as coderd
    participant DB as site_configs

    A->>S: PUT /api/v2/oauth2-provider/settings<br/>{dynamic_client_registration_enabled: true}
    S->>DB: UPSERT oauth2_dcr_enabled = true
    S-->>A: 200 OK
    Note over S,DB: Audited: a new audit_logs row is written

    A->>S: GET /api/v2/oauth2-provider/settings
    S-->>A: 200 OK - dynamic_client_registration_enabled: true
Loading
Commands and output
curl -s -X PUT "$BASE_URL/api/v2/oauth2-provider/settings" \
  -H "$AUTH_HEADER" -H "Content-Type: application/json" \
  -d '{"dynamic_client_registration_enabled": true}' | jq .
{
  "dynamic_client_registration_enabled": true
}
curl -s "$BASE_URL/api/v2/oauth2-provider/settings" -H "$AUTH_HEADER" | jq .
{
  "dynamic_client_registration_enabled": true
}

✅ Pass — write succeeded and persisted; a fresh GET in a separate request confirms it, not just an echo of the PUT body.


3a. Same thing via the CLI

Verifies coder oauth2-provider dcr enable/disable (cli/oauth2provider.go), which wraps the same PUT call as step 3.

sequenceDiagram
    participant A as Owner (CLI)
    participant S as coderd

    A->>S: coder oauth2-provider dcr disable
    S-->>A: "Dynamic client registration is now disabled."
    A->>S: coder oauth2-provider dcr enable
    S-->>A: "Dynamic client registration is now enabled."
Loading
Commands and output
./scripts/coder-dev.sh oauth2-provider dcr disable
curl -s "$BASE_URL/api/v2/oauth2-provider/settings" -H "$AUTH_HEADER" | jq .
Dynamic client registration is now disabled.
{
  "dynamic_client_registration_enabled": false
}
./scripts/coder-dev.sh oauth2-provider dcr enable
curl -s "$BASE_URL/api/v2/oauth2-provider/settings" -H "$AUTH_HEADER" | jq .
Dynamic client registration is now enabled.
{
  "dynamic_client_registration_enabled": true
}

✅ Pass — both CLI subcommands round-trip correctly, each verified via a fresh API GET, not just the CLI's own success message.


3b. Non-owner member gets the same 403 via the CLI

Verifies the CLI surfaces the RBAC error from step 2 rather than swallowing it. coder-dev.sh unsets CODER_SESSION_TOKEN internally, so use the --token global flag to authenticate as the member for one invocation.

Command and output
./scripts/coder-dev.sh --token "$MEMBER_SESSION_TOKEN" oauth2-provider dcr enable
Encountered an error running "coder oauth2-provider dcr enable", see "coder oauth2-provider dcr enable --help" for more information
error: Trace=[unable to enable dynamic client registration: ]
Forbidden.
You don't have permission to view this content. If you believe this is a mistake, please contact your administrator or try signing in with different credentials.

✅ Pass — CLI surfaced the wrapped RBAC 403 Forbidden error rather than swallowing it.


4. Once enabled: an anonymous client discovers and registers successfully

Verifies the flip side of step 1 — registration_endpoint now appears in discovery, and POST /oauth2/register succeeds. This client is kept alive for step 5.

sequenceDiagram
    participant C as Anonymous OAuth2 Client
    participant S as coderd

    C->>S: GET /.well-known/oauth-authorization-server
    S-->>C: 200 OK - registration_endpoint now present

    C->>S: POST /oauth2/register
    S-->>C: 201 Created - client_id, client_secret, registration_access_token
Loading
Commands and output
curl -s "$BASE_URL/.well-known/oauth-authorization-server" | jq '.registration_endpoint'
"http://127.0.0.1:3000/oauth2/register"
DCR_REG=$(curl -s -X POST "$BASE_URL/oauth2/register" \
  -H "Content-Type: application/json" \
  -d '{"client_name": "manual-test-dcr-client", "redirect_uris": ["http://localhost:9876/callback"]}')
echo "$DCR_REG" | jq .
{
  "client_id": "a99e9f20-fcbe-4e48-b3d4-7400e2f83b2f",
  "client_secret": "coder_sFWAcdm1If_IXPH7AFVqcJ9Gxqz0AQgUYiKZ5iCe0ZQwj28s53Z",
  "client_id_issued_at": 1784523093,
  "redirect_uris": ["http://localhost:9876/callback"],
  "client_name": "manual-test-dcr-client",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "client_secret_basic",
  "registration_access_token": "cDQeLvAvBJNKmC5LYZqbOBzWxUAU14XH5yNwwFZL",
  "registration_client_uri": "http://127.0.0.1:3000/oauth2/clients/a99e9f20-fcbe-4e48-b3d4-7400e2f83b2f"
}

✅ Pass — registration succeeded with client_id, client_secret, and registration_access_token all present.


5. Owner disables DCR again: new registrations rejected, this client keeps working

The core scope boundary of the whole feature: disabling DCR is forward-looking only. Verifies registration.go's gate rejects new attempts, while RFC 7592 self-management and the token endpoints (neither of which check the flag) stay unaffected for a client that already exists.

sequenceDiagram
    participant A as Owner
    participant New as New Anonymous Client
    participant Old as Existing Client (from step 4)
    participant S as coderd

    A->>S: PUT /api/v2/oauth2-provider/settings<br/>{dynamic_client_registration_enabled: false}
    S-->>A: 200 OK

    New->>S: POST /oauth2/register
    S-->>New: 403 invalid_request

    Note over Old,S: The client from step 4 was never told to stop existing
    Old->>S: GET /oauth2/clients/{client_id}
    S-->>Old: 200 OK - unaffected

    Old->>S: PKCE authorize + token exchange
    S-->>Old: 200 OK - access_token, refresh_token
    Note over Old,S: Fully functional despite DCR being disabled deployment-wide
Loading
Commands and output
curl -s -X PUT "$BASE_URL/api/v2/oauth2-provider/settings" \
  -H "$AUTH_HEADER" -H "Content-Type: application/json" \
  -d '{"dynamic_client_registration_enabled": false}' | jq .
{
  "dynamic_client_registration_enabled": false
}
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/oauth2/register" \
  -H "Content-Type: application/json" \
  -d '{"client_name": "manual-test-dcr-should-fail", "redirect_uris": ["http://localhost:9876/callback"]}'
{"error":"invalid_request","error_description":"Dynamic client registration is disabled on this deployment"}
HTTP 403
curl -s -o /dev/null -w "%{http_code}\n" "$BASE_URL/oauth2/clients/$DCR_CLIENT_ID" \
  -H "Authorization: Bearer $DCR_REG_ACCESS_TOKEN"
200
# PKCE authorize + token exchange for the existing client
VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c -43)
CHALLENGE=$(echo -n "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d "=" | tr '+/' '-_')
AUTH_URL="$BASE_URL/oauth2/authorize?client_id=$DCR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:9876/callback&state=$(openssl rand -hex 16)&code_challenge=$CHALLENGE&code_challenge_method=S256"
REDIRECT=$(curl -s -X POST "$AUTH_URL" -H "$AUTH_HEADER" -w '\n%{redirect_url}' -o /dev/null)
CODE=$(echo "$REDIRECT" | grep -oE 'code=[^&]+' | sed 's/code=//')

curl -s -X POST "$BASE_URL/oauth2/tokens" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" -d "code=$CODE" -d "client_id=$DCR_CLIENT_ID" \
  -d "client_secret=$DCR_CLIENT_SECRET" -d "redirect_uri=http://localhost:9876/callback" \
  -d "code_verifier=$VERIFIER" | jq .
{
  "access_token": "vAL2d2LeM6-DGh1QsXEYeYvXBzlAJFwv0",
  "token_type": "Bearer",
  "expires_in": 86399,
  "refresh_token": "coder_leYIu6gbh6_2N4XLCNb8xR6de1XOMIp3UN9Om94fRvmmTje8Y6n",
  "expiry": "2026-07-21T04:56:09.579256Z"
}

✅ Pass — new registration rejected, discovery omits the endpoint again, and the pre-existing client's self-management and full PKCE authorize/token exchange both succeed unaffected. Confirms disabling DCR blocks only new self-registration, never already-registered clients.


6. Cleanup

Command and output
curl -s -X DELETE "$BASE_URL/api/v2/oauth2-provider/apps/$DCR_CLIENT_ID" -H "$AUTH_HEADER" \
  -o /dev/null -w "%{http_code}\n"
204

✅ Pass — test app deleted.


7a. Debug-log visibility for a DCR-disabled rejection

registration.go only has one explicit log call, and it's for a genuine internal failure (500), unrelated to the DCR-disabled gate. A rejected registration (403) produces no application log at all — only the generic HTTP request-logging middleware, and only at Debug level for 403s. So seeing this in server logs requires CODER_VERBOSE=true; absent that, the JSON error body returned to the client (already captured in steps 1/5 above) is the actual source of truth.

N/A — documented behavior, not a pass/fail check.


7b. Audit log records the setting change — found and fixed a real bug

Verifies the DCR setting change is durably audited with a correct before/after diff.

Command and output — before the fix
curl -s "$BASE_URL/api/v2/audit?q=resource_type:oauth2_provider_settings" -H "$AUTH_HEADER" \
  | jq '.audit_logs[] | {action, diff}'
{"action": "write", "diff": {"dynamic_client_registration_enabled": {"old": false, "new": true, "secret": false}}}
{"action": "write", "diff": {}}

Every enable row correctly showed old: false, new: true. Every disable row showed an empty diff, hiding that anything had changed.

Root cause: putOAuth2ProviderSettings (coderd/oauth2.go) only ever set aReq.New, never aReq.Old. audit.InitRequest zero-initializes aReq.Old to database.OAuth2ProviderSettings{} (DynamicClientRegistrationEnabled: false), so the diff compared against that zero value instead of the real prior state. Going false → true, the zero value happened to match the real prior state, so the diff came out correct by coincidence. Going true → false, the zero-value Old (false) matched the real New (false), so Diff() reported no change at all.

Fix: putOAuth2ProviderSettings now calls GetOAuth2DCREnabled(ctx) before the upsert (same never-configured/RBAC-error handling as the GET handler) and sets aReq.Old from the real previous value.

Regression coverage: TestOAuth2ProviderSettingsAuditDiff (enterprise/coderd/oauth2providersettings_audit_test.go) asserts real old/new values on both directions. This needed a real entaudit.NewAuditor, since the audit.NewMock() used elsewhere always stubs the diff to Map{} and can't catch this class of bug. Confirmed the test fails against the pre-fix code and passes against the fix.

Command and output — after the fix
curl -s "$BASE_URL/api/v2/audit?q=resource_type:oauth2_provider_settings" -H "$AUTH_HEADER" \
  | jq '.audit_logs[] | {action, diff}'
{"action": "write", "diff": {"dynamic_client_registration_enabled": {"old": true, "new": false, "secret": false}}}
{"action": "write", "diff": {"dynamic_client_registration_enabled": {"old": false, "new": true, "secret": false}}}

✅ Pass — both directions now show correct old/new values, re-verified live against a running dev server on top of the automated test.


7c. coder support bundle is not useful for DCR troubleshooting

Ruled out explicitly since it's the obvious first thing to reach for. Checked support/support.go directly: there is no reference to OAuth2ProviderSettings, oauth2, register, or dcr anywhere in what a support bundle collects. It captures no DCR registration failures and does not show whether the flag is enabled (Deployment.Config is static serpent flags, not the site_configs-backed runtime setting). Use GET /api/v2/oauth2-provider/settings directly, and 7a's debug logging, instead.

N/A — scoping note, ruled out.


8. Testing the upgrade path: existing deployment → this branch

Everything above exercises a deployment created fresh with this branch's schema already in place. This verifies migrations 000546-000549 apply cleanly against a deployment that already existed before this feature, with real pre-existing data.

sequenceDiagram
    participant Old as coderd (origin/main, pre-DCR-flag)
    participant PG as .coderv2/postgres (same data dir both runs)
    participant New as coderd (this branch)

    Old->>PG: migrate up through 000545
    Old->>PG: create admin user, OAuth2 app, etc.
    Note over Old: stop (Ctrl+C)

    New->>PG: migrate up (000546, 000547, 000548, 000549)
    Note over PG: ALTER TYPE resource_type<br/>ADD VALUE 'oauth2_provider_settings'
    New->>PG: existing users/apps still readable
    New->>PG: GET/PUT oauth2-provider/settings works
Loading
Commands and output
# Baseline: checkout origin/main, fresh db, create pre-existing data.
git checkout -B upgrade-test-baseline origin/main
./scripts/develop.sh --db-reset
# ...first-user setup...
curl -s -X POST "$BASE_URL/api/v2/oauth2-provider/apps" -H "$AUTH_HEADER" \
  -H "Content-Type: application/json" \
  -d '{"name": "pre-upgrade-app", "callback_url": "http://localhost:9876/callback"}' | jq '{id, name}'
{
  "id": "6fb24f19-710a-4ab9-80eb-1302791693f0",
  "name": "pre-upgrade-app"
}
# Switch to this branch, same on-disk Postgres data, let it migrate forward.
git checkout coder-eng-3056-dcr-flag
./scripts/develop.sh
Started HTTP listener at http://0.0.0.0:3000
server is ready to accept connections
authenticated as admin user  email=admin@coder.com
# Confirm the migration landed.
PGPASSWORD=$(cat .coderv2/postgres/password) psql -h localhost -p $(cat .coderv2/postgres/port) -U coder -d coder \
  -c "SELECT version, dirty FROM schema_migrations;" \
  -c "\dT+ resource_type"
 version | dirty 
---------+-------
     549 |     f

oauth2_provider_settings present in the resource_type enum.

# Confirm pre-existing data survived.
curl -s "$BASE_URL/api/v2/oauth2-provider/apps" -H "$AUTH_HEADER" | jq '.[] | {id, name}'
{
  "id": "6fb24f19-710a-4ab9-80eb-1302791693f0",
  "name": "pre-upgrade-app"
}
# Confirm the new feature works against the upgraded database.
curl -s "$BASE_URL/api/v2/oauth2-provider/settings" -H "$AUTH_HEADER" | jq .
./scripts/coder-dev.sh oauth2-provider dcr enable
curl -s "$BASE_URL/api/v2/oauth2-provider/settings" -H "$AUTH_HEADER" | jq .
{"dynamic_client_registration_enabled": false}
{"dynamic_client_registration_enabled": true}

✅ Pass — the migration applies cleanly against a database that predates this feature, pre-existing data survives untouched, and both the read and write paths work correctly against the upgraded (not freshly-created) deployment.

000549's down migration is a no-op (Postgres can't drop enum values), so this migration is one-directional — matches the same constraint already present on the sibling public-client PR's migration.


Automated equivalents

Every case above also has an automated test that's passing:

Manual step Automated test
1 TestOAuth2ProviderSettings/DefaultDisabled; TestGetAuthorizationServerMetadata_DCREnabled/NeverConfiguredDefaultsToOmitted, TestCreateDynamicClientRegistration_DCREnabled/NeverConfiguredDefaultsToDisabled
2 TestOAuth2ProviderSettings/PermissionDenied/{Get,Put}
3 TestOAuth2ProviderSettings/RoundTrip
3a, 3b TestOAuth2ProviderDCR (Enable/Disable), TestOAuth2ProviderDCR_RegularUser
4 TestGetAuthorizationServerMetadata_DCREnabled/EnabledAdvertisesRegistrationEndpoint, TestCreateDynamicClientRegistration_DCREnabled/EnabledAllowsRegistration
5 TestOAuth2DynamicClientRegistrationDisabled (all four subtests)
7b TestOAuth2ProviderSettingsAuditDiff — caught the empty-diff-on-disable bug found manually in this step
8 TestMigration000549AuditOAuth2ProviderSettingsEnumInSingleTxn
make test RUN='TestOAuth2ProviderSettings|TestOAuth2DynamicClientRegistrationDisabled|TestCreateDynamicClientRegistration_DCREnabled|TestGetAuthorizationServerMetadata_DCREnabled|TestOAuth2ProviderDCR|TestOAuth2ProviderSettingsAuditDiff|TestMigration000549AuditOAuth2ProviderSettingsEnumInSingleTxn'

@BobbyHo
BobbyHo marked this pull request as ready for review July 21, 2026 03:53
@BobbyHo
BobbyHo requested a review from Emyrk July 21, 2026 03:53
@coderagents

coderagents Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Documentation Check

This PR adds an admin-controlled toggle for OAuth2 Dynamic Client Registration (DCR): DCR is now disabled by default, and owners enable/disable it live via coder oauth2-provider dcr enable|disable or GET/PUT /api/v2/oauth2-provider/settings. The auto-generated CLI/API reference (docs/reference/cli/oauth2-provider*.md, docs/reference/api/*.md, docs/reference/api/schemas.md), the audit-log resource table (docs/admin/security/audit-logs.md), and docs/manifest.json are already updated in this PR.

Updates Needed

  • docs/admin/integrations/oauth2-provider.md - Addressed in 43bfeecd. A new "Dynamic Client Registration" section documents the default-disabled behavior, the CLI (coder oauth2-provider dcr enable|disable) and API (GET/PUT /api/v2/oauth2-provider/settings) controls, and the scope note that disabling blocks only new self-registrations while already-registered clients keep working.

Automated review via Coder Agents

@Emyrk Emyrk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving. Detailed comments are inline; one PR-level note:

Flag the breaking change. Since deployments using DCR under ExperimentOAuth2 will have registration stop working on upgrade (intentional per the design), this PR should carry the release/breaking label (I added the label).

Coder Agents on behalf of @Emyrk.

@Emyrk Emyrk added the release/breaking This label is applied to PRs to detect breaking changes as part of the release process label Jul 21, 2026 — with Coder
@github-actions github-actions Bot changed the title feat: add admin-controlled dynamic client registration toggle feat!: add admin-controlled dynamic client registration toggle Jul 21, 2026

Emyrk commented Jul 21, 2026

Copy link
Copy Markdown
Member

For future PRs of this shape, consider stacking instead of one 54-file PR:

  1. DB + audit plumbing — queries, dbauthz, audit wiring, migration, all generated files. Bulk of the LoC, zero behavior change.
  2. Settings API + SDK — handlers, routes, codersdk, tests. Setting writable, nothing reads it yet.
  3. The gate — registration/metadata checks, permission grant, tests, EnableDCR ripple. The only behavior change, so release/breaking attaches to a small diff.
  4. CLI.

Your "suggested review order" section is already this list — stacked, it becomes merge structure instead of reading instructions.

Coder Agents on behalf of @Emyrk.

@Emyrk Emyrk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Inline versions of the comments from my earlier approval (which I've trimmed to avoid duplication).

Coder Agents on behalf of @Emyrk.

Comment thread coderd/database/queries/siteconfig.sql
Comment thread cli/oauth2provider.go Outdated
Comment thread coderd/oauth2provider/metadata.go Outdated
Comment thread coderd/oauth2.go Outdated
# Conflicts:
#	docs/admin/security/audit-logs.md
@Emyrk

Emyrk commented Jul 22, 2026

Copy link
Copy Markdown
Member

Filed follow-up issues for exposing the new setting beyond the API/CLI, both assigned to @BobbyHo:

Coder Agents on behalf of @Emyrk.

@Emyrk Emyrk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LG 👍

@BobbyHo

BobbyHo commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Reviewing from the downstream side — I'm building the terraform-provider-coderd resource that wraps this endpoint (ENG-3083 / terraform-provider-coderd#395), so I've spent a while with the SDK contract. Two observations. Neither blocks this PR, and the first is considerably more important than the second.

Observation Severity Needs fixing here?
1 Whole-object PUT will silently wipe the IAT field once it lands Medium — silent security regression, no race required No, but must not be forgotten
2 Audit old can still be stale under concurrent PUTs, despite the InTx Low — audit accuracy only No

1. The PUT is a whole-object replace, and IAT arrives later

I gather initial_access_token_required is destined for this same struct, but in a subsequent PR. That timing is what creates the hazard — if both fields shipped together, every client would have been compiled against a two-field struct from day one. Because the second arrives later, there will necessarily be a window where clients built against today's one-field struct talk to servers that have two.

Today codersdk/oauth2.go has:

type OAuth2ProviderSettings struct {
    DynamicClientRegistrationEnabled bool `json:"dynamic_client_registration_enabled"`
}

and coderd/oauth2.go decodes it unconditionally:

var req codersdk.OAuth2ProviderSettings
if !httpapi.Read(ctx, rw, r, &req) { return }
...
tx.UpsertOAuth2DCREnabled(ctx, req.DynamicClientRegistrationEnabled)

Once IAT exists as a second plain bool, an admin who has IAT required loses it the next time an older client toggles DCR:

  1. The old client marshals its one-field struct: {"dynamic_client_registration_enabled": true}.
  2. The server decodes into the two-field struct; initial_access_token_required is absent.
  3. Absent JSON boolean → Go zero value → false.
  4. The handler upserts both fields. The IAT requirement is silently switched off.

No race, no concurrency — one user, one request. And Terraform users pin their provider version independently of their Coder version, so client/server skew is the routine state of affairs rather than an edge case.

The fix is pointer fields, so absent means "leave alone" rather than "set false", with the handler upserting only what was actually sent:

DynamicClientRegistrationEnabled *bool `json:"dynamic_client_registration_enabled,omitempty"`

To be clear, this does not have to happen in this PR. Switching bool*bool + omitempty later is wire-compatible: old clients always emit the key, so their behaviour is unchanged, and only omitting gains new meaning. The genuine risk is simply that it gets forgotten and IAT ships as a plain bool, at which point the wipe goes live. So either make the field a pointer now while everyone has the context loaded, or drop a blocking note on the IAT ticket — both work.

One thing worth ruling out: an ETag/If-Match scheme would not help here. An old client would GET, silently discard the field it can't represent, and PUT back with a perfectly valid precondition — wiping IAT anyway. Optimistic concurrency protects against concurrent modification; this is a client that cannot represent a field. Only partial-update semantics fix it.

2. Follow-up on the audit old value

Re: #3623454406 — the InTx in 324a8f39f9 definitely helped, but I don't think it gets all the way to exact, and it seemed worth saying so rather than leaving the thread implying it does.

GetOAuth2DCREnabled is a plain SELECT with no FOR UPDATE (coderd/database/queries/siteconfig.sql), and the handler passes only TxIdentifier, so Isolation is zero → "the driver or database's default level" (coderd/database/db.go:80-82) → READ COMMITTED, which permits the lost-update anomaly. InTx's automatic retry also only engages at SERIALIZABLE (db.go:161), so there's no retry here.

The consequence is that wrapping the two statements in a transaction doesn't make the read exclusive. A plain SELECT takes no lock and blocks nobody, so two concurrent PUTs can both read the pre-state. Starting from false, with A setting true and B setting false:

A B row
1 SELECTfalse, audit old = false false
2 SELECTfalse, audit old = false false
3 UPSERT true (takes row lock) false
4 UPSERT falseblocks
5 COMMIT — audit false → true true
6 unblocks, DO UPDATE applies to the new row version, writes false
7 COMMIT — audit false → false false

The setting ends up correct — B committed last, B's value won. The audit log is what suffers:

admin-a   oauth2_provider_settings   false → true
admin-b   oauth2_provider_settings   false → false     ← reads as a no-op

B's entry claims old = false, but the row held true when B's write landed, so the chain doesn't join up and B's change looks like it did nothing — when B is in fact the admin who disabled DCR. The row lock is taken at the write, not the read, so blocking makes B's write correct while leaving its audit metadata stale.

To be fair to the fix: the InTx shrank the window from "the whole handler, potentially across two pooled connections" to "two back-to-back statements on one connection". That moved this from reliably wrong under modest concurrency to wrong only under a true microsecond-scale race, which is a real improvement.

If exactness matters, the options are roughly:

  • SELECT ... FOR UPDATE — B's read then blocks until A commits. Caveat: on a never-configured deployment the row doesn't exist yet and FOR UPDATE locks nothing on a missing row, so the first concurrent writes could still race.
  • Capture the old value inside the upsert via a CTE, dropping the separate SELECT. Cleanest for this shape, though a CTE's branches share a statement snapshot, so it deserves a test rather than being assumed correct.
  • Isolation: sql.LevelSerializable — heavier, but InTx already has retry wired for that level.

Genuinely low severity, and the original "OK as-is for an owner-only boolean toggle" still stands. Flagging it only because the thread aimed at exact. I have not reproduced this — it's from reading the code plus documented READ COMMITTED semantics, and confirming it properly needs a real Postgres and two concurrent requests, i.e. a test on your side rather than mine.

BobbyHo and others added 3 commits July 27, 2026 07:43
DynamicClientRegistrationEnabled is now *bool with omitempty so a PUT
that omits the field leaves the current value unchanged, instead of
decoding to false and silently clearing it. This matters once a second
field lands in this struct: without this, an older client built
against today's single-field version would always encode the newer
field's zero value on every unrelated update, silently disabling it.

GET always returns a concrete, non-nil value.

Also regenerates docs/admin/security/audit-logs.md against a truly
fresh _gen/bin/auditdocgen build; the version committed by the earlier
merge-conflict fix was generated against a stale binary and didn't
reproduce identically in CI's fresh `make gen`.

Addresses: #27316 (comment)
TestMigration000551AuditOAuth2ProviderSettingsEnumInSingleTxn still referenced migration 551 after an earlier merge-conflict fix renumbered the audit_oauth2_provider_settings migration to 552. Renamed the test and updated both migration ranges to reference 552.
BobbyHo added a commit to coder/terraform-provider-coderd that referenced this pull request Jul 27, 2026
`codersdk.OAuth2ProviderSettings` and the `OAuth2ProviderSettings` /
`PutOAuth2ProviderSettings` client methods are introduced by
coder/coder#27316, which is required by the `coderd_oauth2_provider_settings`
resource.

TEMPORARY PIN: #27316 has not merged, so no released coder/coder version
exposes these symbols. This pins the PR's head commit
(569a0eb3411212ff080b836f514d1dfe4386ccc7, branch coder-eng-3056-dcr-flag) so
the resource compiles and its tests run. Re-pin to a released version before
merging.

Transitively bumps the `go` directive to 1.26.5 and ~12 indirect
dependencies.

Refs #395
BobbyHo added a commit to coder/terraform-provider-coderd that referenced this pull request Jul 27, 2026
coder/coder#27316 adds a deployment-level toggle for OAuth2 Dynamic Client
Registration (RFC 7591), reachable via `coder oauth2-provider dcr
enable|disable` or a raw `PUT /api/v2/oauth2-provider/settings`. Neither is
declarative, so a deployment managed by this provider had no way to express
"DCR is enabled here" as Terraform state, short of a `local-exec` provisioner
that participates in neither plan/diff, drift detection, nor destroy.

Add a `coderd_oauth2_provider_settings` resource and a data source of the same
name, modelled on `coderd_organization_sync_settings`: a singleton wrapping a
GET-for-read / PUT-for-write API with no delete endpoint.

Resource:
- `dynamic_client_registration_enabled` is Required. Opting out means not
  declaring the resource; a plain Optional attribute would refresh to a
  concrete bool against a null plan and produce a perpetual diff.
- Create and Update share one idempotent PUT; the API has no separate create.
- Delete resets to the documented default (false), since no DELETE verb
  exists. A failed reset keeps the resource in state so destroy can retry.
- ImportState allows adopting an already-configured deployment without
  overwriting it. The import ID is an unused placeholder: the resource is a
  deployment-wide singleton and Read() takes no parameters.
- ModifyPlan warns at plan time when a first apply would disable DCR on a
  deployment where it is currently enabled. Deliberately asymmetric: a live
  `false` is indistinguishable from never-configured, so warning in that
  direction would fire on every greenfield apply.
- A 404 is reported as an unsupported Coder version rather than treated as a
  deleted resource. This singleton always exists on a supported deployment, so
  404 can only mean the endpoint is missing; removing state would hide a
  version problem behind a phantom diff.

Data source: read-only, GET only, never PUT. The setting is a deployment
singleton, so only one configuration can own the resource; any other that
needs the value reads it here. It also works with tokens holding read but not
write access on the deployment config, a broader set of roles than the
resource requires.

`DynamicClientRegistrationEnabled` is a `*bool`, so that a PUT can omit it to
leave the value unchanged. Both write paths therefore send an explicit
non-nil pointer: this resource owns the value outright, and omitting it would
make Create/Update no-ops and silently turn Delete's reset-to-default into a
no-op. Reads go through `dcrEnabledOrDefault`, which falls back to the
deployment default if the field is ever nil -- a GET is documented to always
return non-nil, so that branch is defensive against a contract violation.

Tests run against a recording httptest fake rather than
`integration.StartCoder`, which pulls a published coder image that does not
yet contain #27316. The fake also allows asserting requests that must *not*
happen -- import never PUTs, the data source never PUTs, an undeclared
resource issues no calls at all -- and injecting 403/404/5xx responses that a
real deployment will not produce on demand. It records whether the DCR field
was present on each PUT, not just its value, so a regression that stopped
sending it would fail rather than pass silently.

Closes #395
BobbyHo added a commit that referenced this pull request Jul 27, 2026
…nt settings

Surfaces the admin-controlled DCR setting from GET/PUT
/api/v2/oauth2-provider/settings (added in #27316) on the OAuth2
Applications deployment settings page. Enabling the switch requires
confirming a warning dialog, since it lets any client self-register
against the deployment per RFC 7591; disabling is immediate.
BobbyHo and others added 2 commits July 27, 2026 10:46
main independently added 000552_ai_budget_notifications since the last sync, colliding with this branch's 000552_audit_oauth2_provider_settings. Renumbered to 000553 and updated the corresponding single-txn enum test (name and both migration ranges) to match.
BobbyHo added a commit that referenced this pull request Jul 27, 2026
…nt settings

Surfaces the admin-controlled DCR setting from GET/PUT
/api/v2/oauth2-provider/settings (added in #27316) on the OAuth2
Applications deployment settings page. Enabling the switch requires
confirming a warning dialog, since it lets any client self-register
against the deployment per RFC 7591; disabling is immediate.
BobbyHo and others added 3 commits July 28, 2026 09:05
Resolve two conflicts:

- coderd/database/migrations/migrate_test.go: both branches appended a
  migration test at the same location. Keep both, and renumber this
  branch's migration from 000553 to 000556 since main now ends at
  000555 (000553_ai_budget_admin_notifications collided). None of
  main's new migrations alter a type, so the final schema and the
  resource_type enum value ordering in dump.sql are unchanged.

- docs/reference/api/enterprise.md: generated output where both
  branches added an endpoint section at the same position. Regenerated
  from the merged Go swagger annotations rather than hand-merged.
…n with main

main landed 000556_user_secrets_enabled (#27537), which collides with
this branch's 000556_audit_oauth2_provider_settings. golang-migrate's
iofs driver rejects the whole migration set on a duplicate ordinal, so
every table was missing and sqlc vet failed with a flood of
'relation does not exist' errors.

Renumber to 000557 and update the test's migration range. The migration
adds an enum value and 000556_user_secrets_enabled alters no types, so
the resulting schema is unchanged: dump.sql is byte-identical after
regeneration.
BobbyHo added a commit that referenced this pull request Jul 28, 2026
…nt settings

Surfaces the admin-controlled DCR setting from GET/PUT
/api/v2/oauth2-provider/settings (added in #27316) on the OAuth2
Applications deployment settings page. Enabling the switch requires
confirming a warning dialog, since it lets any client self-register
against the deployment per RFC 7591; disabling is immediate.
@BobbyHo
BobbyHo marked this pull request as draft July 28, 2026 18:00
@BobbyHo
BobbyHo marked this pull request as ready for review July 28, 2026 23:15
…n with main

The rebase brought in 000557_connection_type_tunnel, which collides with
this branch's 000557_audit_oauth2_provider_settings. golang-migrate's
iofs driver rejects the entire migration set on a duplicate ordinal, so
dump.sql generation, sqlc vet, and the e2e server all failed to start.

That single collision accounts for the gen, lint, offlinedocs, sqlc-vet,
and test-e2e failures.

main's 000557 adds a value to connection_type while this migration adds
one to resource_type, so ordering between them does not matter and
dump.sql is byte-identical after regeneration.
@BobbyHo
BobbyHo merged commit fbac602 into main Jul 28, 2026
31 of 32 checks passed
@BobbyHo
BobbyHo deleted the coder-eng-3056-dcr-flag branch July 28, 2026 23:59
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

release/breaking This label is applied to PRs to detect breaking changes as part of the release process

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants