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

`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](https://linear.app/codercom/issue/ENG-3056/oauth2-dcr-admin-configurable-enabledisable).

## Where this sits in the request path

```mermaid
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)
```

## 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.

<details>
<summary><b>Manual files (26)</b> — click to expand, grouped the same
way as "Suggested review order" below</summary>

**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. |

</details>

<details>
<summary><b>Generated files (12)</b> — from <code>make gen</code>, no
need to review directly</summary>

`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`.

</details>

## 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).
This commit is contained in:
Bobby Ho
2026-07-28 16:59:33 -07:00
committed by GitHub
parent ce769680ba
commit fbac602456
55 changed files with 1620 additions and 76 deletions
+96
View File
@@ -0,0 +1,96 @@
package cli
import (
"fmt"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) oauth2Provider() *serpent.Command {
cmd := &serpent.Command{
Use: "oauth2-provider",
Short: "Manage Coder OAuth2 provider settings",
Long: "Administrators can use these commands to change OAuth2 provider settings.\n" + FormatExamples(
Example{
Description: "Enable dynamic client registration (RFC 7591), allowing OAuth2/MCP clients to self-register without an admin creating an app first",
Command: "coder oauth2-provider dcr enable",
},
Example{
Description: "Disable dynamic client registration. Clients that already registered are unaffected; only new self-registration attempts are rejected",
Command: "coder oauth2-provider dcr disable",
},
),
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
r.oauth2ProviderDCR(),
},
}
return cmd
}
func (r *RootCmd) oauth2ProviderDCR() *serpent.Command {
cmd := &serpent.Command{
Use: "dcr",
Short: "Manage OAuth2 dynamic client registration (RFC 7591)",
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
r.oauth2ProviderDCRToggle(dcrToggleEnable),
r.oauth2ProviderDCRToggle(dcrToggleDisable),
},
}
return cmd
}
// dcrToggleAction distinguishes the "enable" and "disable" subcommands of
// `coder oauth2-provider dcr`, which are otherwise identical.
type dcrToggleAction int
const (
dcrToggleDisable dcrToggleAction = iota
dcrToggleEnable
)
func (r *RootCmd) oauth2ProviderDCRToggle(action dcrToggleAction) *serpent.Command {
enabled := action == dcrToggleEnable
use, short, verb := "disable", "Disable OAuth2 dynamic client registration", "disable"
if enabled {
use, short, verb = "enable", "Enable OAuth2 dynamic client registration", "enable"
}
cmd := &serpent.Command{
Use: use,
Short: short,
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
),
Handler: func(inv *serpent.Invocation) error {
client, err := r.InitClient(inv)
if err != nil {
return err
}
_, err = client.PutOAuth2ProviderSettings(inv.Context(), codersdk.OAuth2ProviderSettings{
DynamicClientRegistrationEnabled: ptr.Ref(enabled),
})
if err != nil {
return xerrors.Errorf("unable to %s dynamic client registration: %w", verb, err)
}
state := "disabled"
if enabled {
state = "enabled"
}
_, _ = fmt.Fprintf(inv.Stderr, "Dynamic client registration is now %s.\n", state)
return nil
},
}
return cmd
}
+82
View File
@@ -0,0 +1,82 @@
package cli_test
import (
"bytes"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestOAuth2ProviderDCR(t *testing.T) {
t.Parallel()
tests := []struct {
name string
command string
expectValue bool
expectMsg string
}{
{
name: "Enable",
command: "enable",
expectValue: true,
expectMsg: "Dynamic client registration is now enabled.",
},
{
name: "Disable",
command: "disable",
expectValue: false,
expectMsg: "Dynamic client registration is now disabled.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
inv, root := clitest.New(t, "oauth2-provider", "dcr", tt.command)
clitest.SetupConfig(t, client, root)
var buf bytes.Buffer
inv.Stderr = &buf
err := inv.Run()
require.NoError(t, err)
assert.Contains(t, buf.String(), tt.expectMsg)
ctx := testutil.Context(t, testutil.WaitShort)
settings, err := client.OAuth2ProviderSettings(ctx)
require.NoError(t, err)
require.NotNil(t, settings.DynamicClientRegistrationEnabled, "GET must always return a concrete value")
require.Equal(t, tt.expectValue, *settings.DynamicClientRegistrationEnabled)
})
}
}
func TestOAuth2ProviderDCR_RegularUser(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
anotherClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
inv, root := clitest.New(t, "oauth2-provider", "dcr", "enable")
clitest.SetupConfig(t, anotherClient, root)
var buf bytes.Buffer
inv.Stderr = &buf
err := inv.Run()
var sdkError *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
}
+1
View File
@@ -109,6 +109,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
r.logout(),
r.netcheck(),
r.notifications(),
r.oauth2Provider(),
r.organizations(),
r.portForward(),
r.publickey(),
+52 -51
View File
@@ -14,57 +14,58 @@ USAGE:
$ coder templates init
SUBCOMMANDS:
autoupdate Toggle auto-update policy for a workspace
completion Install or update shell completion scripts for the
detected or chosen shell.
config-ssh Add an SSH Host entry for your workspaces "ssh
workspace.coder"
create Create a workspace
delete Delete a workspace
dotfiles Personalize your workspace by applying a canonical
dotfiles repository
external-auth Manage external authentication
favorite Add a workspace to your favorites
list List workspaces
login Authenticate with Coder deployment
logout Unauthenticate your local session
logs View logs for a workspace
netcheck Print network debug information for DERP and STUN
notifications Manage Coder notifications
open Open a workspace
organizations Organization related commands
ping Ping a workspace
port-forward Forward ports from a workspace to the local machine. For
reverse port forwarding, use "coder ssh -R".
provisioner View and manage provisioner daemons and jobs
publickey Output your Coder public key used for Git operations
rename Rename a workspace
reset-password Directly connect to the database to reset a user's
password
restart Restart a workspace
schedule Schedule automated start and stop times for workspaces
secret Manage secrets
server Start a Coder server
show Display details of a workspace's resources and agents
speedtest Run upload and download tests from your machine to a
workspace
ssh Start a shell into a workspace or run a command
start Start a workspace
stat Show resource usage for the current workspace.
state Manually manage Terraform state to fix broken workspaces
stop Stop a workspace
support Commands for troubleshooting issues with a Coder
deployment.
task Manage tasks
templates Manage templates
tokens Manage personal access tokens
unfavorite Remove a workspace from your favorites
update Will update and start a given workspace if it is out of
date. If the workspace is already running, it will be
stopped first.
users Manage users
version Show coder version
whoami Fetch authenticated user info for Coder deployment
autoupdate Toggle auto-update policy for a workspace
completion Install or update shell completion scripts for the
detected or chosen shell.
config-ssh Add an SSH Host entry for your workspaces "ssh
workspace.coder"
create Create a workspace
delete Delete a workspace
dotfiles Personalize your workspace by applying a canonical
dotfiles repository
external-auth Manage external authentication
favorite Add a workspace to your favorites
list List workspaces
login Authenticate with Coder deployment
logout Unauthenticate your local session
logs View logs for a workspace
netcheck Print network debug information for DERP and STUN
notifications Manage Coder notifications
oauth2-provider Manage Coder OAuth2 provider settings
open Open a workspace
organizations Organization related commands
ping Ping a workspace
port-forward Forward ports from a workspace to the local machine. For
reverse port forwarding, use "coder ssh -R".
provisioner View and manage provisioner daemons and jobs
publickey Output your Coder public key used for Git operations
rename Rename a workspace
reset-password Directly connect to the database to reset a user's
password
restart Restart a workspace
schedule Schedule automated start and stop times for workspaces
secret Manage secrets
server Start a Coder server
show Display details of a workspace's resources and agents
speedtest Run upload and download tests from your machine to a
workspace
ssh Start a shell into a workspace or run a command
start Start a workspace
stat Show resource usage for the current workspace.
state Manually manage Terraform state to fix broken workspaces
stop Stop a workspace
support Commands for troubleshooting issues with a Coder
deployment.
task Manage tasks
templates Manage templates
tokens Manage personal access tokens
unfavorite Remove a workspace from your favorites
update Will update and start a given workspace if it is out of
date. If the workspace is already running, it will be
stopped first.
users Manage users
version Show coder version
whoami Fetch authenticated user info for Coder deployment
GLOBAL OPTIONS:
Global options are applied to all commands. They can be set using environment
+24
View File
@@ -0,0 +1,24 @@
coder v0.0.0-devel
USAGE:
coder oauth2-provider
Manage Coder OAuth2 provider settings
Administrators can use these commands to change OAuth2 provider settings.
- Enable dynamic client registration (RFC 7591), allowing OAuth2/MCP clients
to
self-register without an admin creating an app first:
$ coder oauth2-provider dcr enable
- Disable dynamic client registration. Clients that already registered are
unaffected; only new self-registration attempts are rejected:
$ coder oauth2-provider dcr disable
SUBCOMMANDS:
dcr Manage OAuth2 dynamic client registration (RFC 7591)
———
Run `coder --help` for a list of global options.
+13
View File
@@ -0,0 +1,13 @@
coder v0.0.0-devel
USAGE:
coder oauth2-provider dcr
Manage OAuth2 dynamic client registration (RFC 7591)
SUBCOMMANDS:
disable Disable OAuth2 dynamic client registration
enable Enable OAuth2 dynamic client registration
———
Run `coder --help` for a list of global options.
@@ -0,0 +1,9 @@
coder v0.0.0-devel
USAGE:
coder oauth2-provider dcr disable
Disable OAuth2 dynamic client registration
———
Run `coder --help` for a list of global options.
@@ -0,0 +1,9 @@
coder v0.0.0-devel
USAGE:
coder oauth2-provider dcr enable
Enable OAuth2 dynamic client registration
———
Run `coder --help` for a list of global options.