mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
`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).
300 lines
10 KiB
Go
300 lines
10 KiB
Go
package codersdk
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type ResourceType string
|
|
|
|
const (
|
|
ResourceTypeTemplate ResourceType = "template"
|
|
ResourceTypeTemplateVersion ResourceType = "template_version"
|
|
ResourceTypeUser ResourceType = "user"
|
|
ResourceTypeWorkspace ResourceType = "workspace"
|
|
ResourceTypeWorkspaceBuild ResourceType = "workspace_build"
|
|
ResourceTypeGitSSHKey ResourceType = "git_ssh_key"
|
|
ResourceTypeAPIKey ResourceType = "api_key"
|
|
ResourceTypeGroup ResourceType = "group"
|
|
ResourceTypeLicense ResourceType = "license"
|
|
ResourceTypeConvertLogin ResourceType = "convert_login"
|
|
ResourceTypeHealthSettings ResourceType = "health_settings"
|
|
ResourceTypeNotificationsSettings ResourceType = "notifications_settings"
|
|
ResourceTypePrebuildsSettings ResourceType = "prebuilds_settings"
|
|
ResourceTypeOAuth2ProviderSettings ResourceType = "oauth2_provider_settings"
|
|
ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy"
|
|
ResourceTypeOrganization ResourceType = "organization"
|
|
ResourceTypeOAuth2ProviderApp ResourceType = "oauth2_provider_app"
|
|
// nolint:gosec // This is not a secret.
|
|
ResourceTypeOAuth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret"
|
|
ResourceTypeCustomRole ResourceType = "custom_role"
|
|
ResourceTypeOrganizationMember ResourceType = "organization_member"
|
|
ResourceTypeNotificationTemplate ResourceType = "notification_template"
|
|
ResourceTypeIdpSyncSettingsOrganization ResourceType = "idp_sync_settings_organization"
|
|
ResourceTypeIdpSyncSettingsGroup ResourceType = "idp_sync_settings_group"
|
|
ResourceTypeIdpSyncSettingsRole ResourceType = "idp_sync_settings_role"
|
|
// Deprecated: Workspace Agent connections are now included in the
|
|
// connection log.
|
|
ResourceTypeWorkspaceAgent ResourceType = "workspace_agent"
|
|
// Deprecated: Workspace App connections are now included in the
|
|
// connection log.
|
|
ResourceTypeWorkspaceApp ResourceType = "workspace_app"
|
|
ResourceTypeTask ResourceType = "task"
|
|
ResourceTypeAISeat ResourceType = "ai_seat"
|
|
ResourceTypeAIProvider ResourceType = "ai_provider"
|
|
ResourceTypeAIProviderKey ResourceType = "ai_provider_key"
|
|
ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key"
|
|
ResourceTypeGroupAIBudget ResourceType = "group_ai_budget"
|
|
ResourceTypeUserAIBudgetOverride ResourceType = "user_ai_budget_override"
|
|
ResourceTypeChat ResourceType = "chat"
|
|
ResourceTypeUserSecret ResourceType = "user_secret"
|
|
ResourceTypeUserSkill ResourceType = "user_skill"
|
|
)
|
|
|
|
func (r ResourceType) FriendlyString() string {
|
|
switch r {
|
|
case ResourceTypeTemplate:
|
|
return "template"
|
|
case ResourceTypeTemplateVersion:
|
|
return "template version"
|
|
case ResourceTypeUser:
|
|
return "user"
|
|
case ResourceTypeWorkspace:
|
|
return "workspace"
|
|
case ResourceTypeWorkspaceBuild:
|
|
// workspace builds have a unique friendly string
|
|
// see coderd/audit.go:298 for explanation
|
|
return "workspace"
|
|
case ResourceTypeGitSSHKey:
|
|
return "git ssh key"
|
|
case ResourceTypeAPIKey:
|
|
return "token"
|
|
case ResourceTypeGroup:
|
|
return "group"
|
|
case ResourceTypeLicense:
|
|
return "license"
|
|
case ResourceTypeConvertLogin:
|
|
return "login type conversion"
|
|
case ResourceTypeWorkspaceProxy:
|
|
return "workspace proxy"
|
|
case ResourceTypeOrganization:
|
|
return "organization"
|
|
case ResourceTypeHealthSettings:
|
|
return "health_settings"
|
|
case ResourceTypeNotificationsSettings:
|
|
return "notifications_settings"
|
|
case ResourceTypePrebuildsSettings:
|
|
return "prebuilds_settings"
|
|
case ResourceTypeOAuth2ProviderSettings:
|
|
return "oauth2 provider settings"
|
|
case ResourceTypeOAuth2ProviderApp:
|
|
return "oauth2 app"
|
|
case ResourceTypeOAuth2ProviderAppSecret:
|
|
return "oauth2 app secret"
|
|
case ResourceTypeCustomRole:
|
|
return "custom role"
|
|
case ResourceTypeOrganizationMember:
|
|
return "organization member"
|
|
case ResourceTypeNotificationTemplate:
|
|
return "notification template"
|
|
case ResourceTypeIdpSyncSettingsOrganization:
|
|
return "settings"
|
|
case ResourceTypeIdpSyncSettingsGroup:
|
|
return "settings"
|
|
case ResourceTypeIdpSyncSettingsRole:
|
|
return "settings"
|
|
case ResourceTypeWorkspaceAgent:
|
|
return "workspace agent"
|
|
case ResourceTypeWorkspaceApp:
|
|
return "workspace app"
|
|
case ResourceTypeTask:
|
|
return "task"
|
|
case ResourceTypeAISeat:
|
|
return "ai seat"
|
|
case ResourceTypeAIProvider:
|
|
return "ai provider"
|
|
case ResourceTypeAIProviderKey:
|
|
return "ai provider key"
|
|
case ResourceTypeAIGatewayKey:
|
|
return "ai gateway key"
|
|
case ResourceTypeGroupAIBudget:
|
|
return "group ai budget"
|
|
case ResourceTypeUserAIBudgetOverride:
|
|
return "user ai budget override"
|
|
case ResourceTypeChat:
|
|
return "chat"
|
|
case ResourceTypeUserSecret:
|
|
return "user secret"
|
|
case ResourceTypeUserSkill:
|
|
return "user skill"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
type AuditAction string
|
|
|
|
const (
|
|
AuditActionCreate AuditAction = "create"
|
|
AuditActionWrite AuditAction = "write"
|
|
AuditActionDelete AuditAction = "delete"
|
|
AuditActionStart AuditAction = "start"
|
|
AuditActionStop AuditAction = "stop"
|
|
AuditActionLogin AuditAction = "login"
|
|
AuditActionLogout AuditAction = "logout"
|
|
AuditActionRegister AuditAction = "register"
|
|
AuditActionRequestPasswordReset AuditAction = "request_password_reset"
|
|
// Deprecated: Workspace connections are now included in the
|
|
// connection log.
|
|
AuditActionConnect AuditAction = "connect"
|
|
// Deprecated: Workspace disconnections are now included in the
|
|
// connection log.
|
|
AuditActionDisconnect AuditAction = "disconnect"
|
|
// Deprecated: Workspace App connections are now included in the
|
|
// connection log.
|
|
AuditActionOpen AuditAction = "open"
|
|
// Deprecated: This action is unused.
|
|
AuditActionClose AuditAction = "close"
|
|
)
|
|
|
|
func (a AuditAction) Friendly() string {
|
|
switch a {
|
|
case AuditActionCreate:
|
|
return "created"
|
|
case AuditActionWrite:
|
|
return "updated"
|
|
case AuditActionDelete:
|
|
return "deleted"
|
|
case AuditActionStart:
|
|
return "started"
|
|
case AuditActionStop:
|
|
return "stopped"
|
|
case AuditActionLogin:
|
|
return "logged in"
|
|
case AuditActionLogout:
|
|
return "logged out"
|
|
case AuditActionRegister:
|
|
return "registered"
|
|
case AuditActionRequestPasswordReset:
|
|
return "password reset requested"
|
|
case AuditActionConnect:
|
|
return "connected"
|
|
case AuditActionDisconnect:
|
|
return "disconnected"
|
|
case AuditActionOpen:
|
|
return "opened"
|
|
case AuditActionClose:
|
|
return "closed"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
type AuditDiff map[string]AuditDiffField
|
|
|
|
type AuditDiffField struct {
|
|
Old any `json:"old,omitempty"`
|
|
New any `json:"new,omitempty"`
|
|
Secret bool `json:"secret"`
|
|
}
|
|
|
|
type AuditLog struct {
|
|
ID uuid.UUID `json:"id" format:"uuid"`
|
|
RequestID uuid.UUID `json:"request_id" format:"uuid"`
|
|
Time time.Time `json:"time" format:"date-time"`
|
|
IP netip.Addr `json:"ip"`
|
|
UserAgent string `json:"user_agent"`
|
|
ResourceType ResourceType `json:"resource_type"`
|
|
ResourceID uuid.UUID `json:"resource_id" format:"uuid"`
|
|
// ResourceTarget is the name of the resource.
|
|
ResourceTarget string `json:"resource_target"`
|
|
ResourceIcon string `json:"resource_icon"`
|
|
Action AuditAction `json:"action"`
|
|
Diff AuditDiff `json:"diff"`
|
|
StatusCode int32 `json:"status_code"`
|
|
AdditionalFields json.RawMessage `json:"additional_fields" swaggertype:"object"`
|
|
Description string `json:"description"`
|
|
ResourceLink string `json:"resource_link"`
|
|
IsDeleted bool `json:"is_deleted"`
|
|
|
|
// Deprecated: Use 'organization.id' instead.
|
|
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
|
|
|
|
Organization *MinimalOrganization `json:"organization,omitempty"`
|
|
|
|
User *User `json:"user"`
|
|
}
|
|
|
|
type AuditLogsRequest struct {
|
|
SearchQuery string `json:"q,omitempty"`
|
|
Pagination
|
|
}
|
|
|
|
type AuditLogResponse struct {
|
|
AuditLogs []AuditLog `json:"audit_logs"`
|
|
Count int64 `json:"count"`
|
|
CountCap int64 `json:"count_cap"`
|
|
}
|
|
|
|
type CreateTestAuditLogRequest struct {
|
|
Action AuditAction `json:"action,omitempty" enums:"create,write,delete,start,stop"`
|
|
ResourceType ResourceType `json:"resource_type,omitempty" enums:"template,template_version,user,workspace,workspace_build,git_ssh_key,auditable_group"`
|
|
ResourceID uuid.UUID `json:"resource_id,omitempty" format:"uuid"`
|
|
AdditionalFields json.RawMessage `json:"additional_fields,omitempty"`
|
|
Time time.Time `json:"time,omitempty" format:"date-time"`
|
|
BuildReason BuildReason `json:"build_reason,omitempty" enums:"autostart,autostop,initiator"`
|
|
OrganizationID uuid.UUID `json:"organization_id,omitempty" format:"uuid"`
|
|
RequestID uuid.UUID `json:"request_id,omitempty" format:"uuid"`
|
|
}
|
|
|
|
// AuditLogs retrieves audit logs from the given page.
|
|
func (c *Client) AuditLogs(ctx context.Context, req AuditLogsRequest) (AuditLogResponse, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, "/api/v2/audit", nil, req.Pagination.asRequestOption(), func(r *http.Request) {
|
|
q := r.URL.Query()
|
|
var params []string
|
|
if req.SearchQuery != "" {
|
|
params = append(params, req.SearchQuery)
|
|
}
|
|
q.Set("q", strings.Join(params, " "))
|
|
r.URL.RawQuery = q.Encode()
|
|
})
|
|
if err != nil {
|
|
return AuditLogResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
return AuditLogResponse{}, ReadBodyAsError(res)
|
|
}
|
|
|
|
var logRes AuditLogResponse
|
|
err = json.NewDecoder(res.Body).Decode(&logRes)
|
|
if err != nil {
|
|
return AuditLogResponse{}, err
|
|
}
|
|
|
|
return logRes, nil
|
|
}
|
|
|
|
// CreateTestAuditLog creates a fake audit log. Only owners of the organization
|
|
// can perform this action. It's used for testing purposes.
|
|
func (c *Client) CreateTestAuditLog(ctx context.Context, req CreateTestAuditLogRequest) error {
|
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/audit/testgenerate", req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|