mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +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).
672 lines
20 KiB
Go
672 lines
20 KiB
Go
package audit
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.opentelemetry.io/otel/baggage"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/idpsync"
|
|
"github.com/coder/coder/v2/coderd/tracing"
|
|
)
|
|
|
|
type RequestParams struct {
|
|
Audit Auditor
|
|
Log slog.Logger
|
|
|
|
// OrganizationID is only provided when possible. If an audit resource extends
|
|
// beyond the org scope, leave this as the nil uuid.
|
|
OrganizationID uuid.UUID
|
|
Request *http.Request
|
|
Action database.AuditAction
|
|
AdditionalFields interface{}
|
|
}
|
|
|
|
type Request[T Auditable] struct {
|
|
params *RequestParams
|
|
|
|
Old T
|
|
New T
|
|
|
|
// UserID is an optional field can be passed in when the userID cannot be
|
|
// determined from the API Key such as in the case of login, when the audit
|
|
// log is created prior the API Key's existence.
|
|
UserID uuid.UUID
|
|
|
|
// Action is an optional field can be passed in if the AuditAction must be
|
|
// overridden such as in the case of new user authentication when the Audit
|
|
// Action is 'register', not 'login'.
|
|
Action database.AuditAction
|
|
}
|
|
|
|
// UpdateOrganizationID can be used if the organization ID is not known
|
|
// at the initiation of an audit log request.
|
|
func (r *Request[T]) UpdateOrganizationID(id uuid.UUID) {
|
|
r.params.OrganizationID = id
|
|
}
|
|
|
|
type BackgroundAuditParams[T Auditable] struct {
|
|
Audit Auditor
|
|
Log slog.Logger
|
|
|
|
UserID uuid.UUID
|
|
RequestID uuid.UUID
|
|
Time time.Time
|
|
Status int
|
|
Action database.AuditAction
|
|
OrganizationID uuid.UUID
|
|
IP string
|
|
UserAgent string
|
|
// todo: this should automatically marshal an interface{} instead of accepting a raw message.
|
|
AdditionalFields json.RawMessage
|
|
|
|
New T
|
|
Old T
|
|
}
|
|
|
|
func ResourceTarget[T Auditable](tgt T) string {
|
|
switch typed := any(tgt).(type) {
|
|
case database.Template:
|
|
return typed.Name
|
|
case database.TemplateVersion:
|
|
return typed.Name
|
|
case database.User:
|
|
return typed.Username
|
|
case database.WorkspaceTable:
|
|
return typed.Name
|
|
case database.WorkspaceBuild:
|
|
// this isn't used
|
|
return ""
|
|
case database.GitSSHKey:
|
|
return typed.PublicKey
|
|
case database.AuditableGroup:
|
|
return typed.Group.Name
|
|
case database.APIKey:
|
|
if typed.TokenName != "nil" {
|
|
return typed.TokenName
|
|
}
|
|
// API Keys without names are used for auth
|
|
// and don't have a target
|
|
return ""
|
|
case database.License:
|
|
return strconv.Itoa(int(typed.ID))
|
|
case database.WorkspaceProxy:
|
|
return typed.Name
|
|
case database.AuditOAuthConvertState:
|
|
return string(typed.ToLoginType)
|
|
case database.HealthSettings:
|
|
return "" // no target?
|
|
case database.NotificationsSettings:
|
|
return "" // no target?
|
|
case database.PrebuildsSettings:
|
|
return "" // no target?
|
|
case database.OAuth2ProviderSettings:
|
|
return "" // no target?
|
|
case database.OAuth2ProviderApp:
|
|
return typed.Name
|
|
case database.OAuth2ProviderAppSecret:
|
|
return typed.DisplaySecret
|
|
case database.CustomRole:
|
|
return typed.Name
|
|
case database.AuditableOrganizationMember:
|
|
return typed.Username
|
|
case database.Organization:
|
|
return typed.Name
|
|
case database.NotificationTemplate:
|
|
return typed.Name
|
|
case idpsync.OrganizationSyncSettings:
|
|
return "Organization Sync"
|
|
case idpsync.GroupSyncSettings:
|
|
return "Organization Group Sync"
|
|
case idpsync.RoleSyncSettings:
|
|
return "Organization Role Sync"
|
|
case database.TaskTable:
|
|
return typed.Name
|
|
case database.AISeatState:
|
|
return "AI Seat"
|
|
case database.AIProvider:
|
|
return typed.Name
|
|
case database.AIProviderKey:
|
|
return typed.ID.String()
|
|
case database.AIGatewayKey:
|
|
return typed.Name
|
|
case database.AuditableGroupAIBudget:
|
|
return typed.GroupName
|
|
case database.AuditableUserAIBudgetOverride:
|
|
return typed.Username
|
|
case database.Chat:
|
|
// Chat titles can contain sensitive content (secrets, internal
|
|
// project names), so we use a short UUID prefix as a display
|
|
// hint instead. The full UUID is still recorded in resource_id,
|
|
// which is what the audit UI links on. An 8-char prefix is fine
|
|
// for display; collisions affect the display label and search
|
|
// filter but not the primary resource identifier.
|
|
return typed.ID.String()[:8]
|
|
case database.UserSecret:
|
|
return typed.Name
|
|
case database.UserSkill:
|
|
return typed.Name
|
|
default:
|
|
panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt))
|
|
}
|
|
}
|
|
|
|
// noID can be used for resources that do not have an uuid.
|
|
// An example is singleton configuration resources.
|
|
// 51A51C = "Static"
|
|
var noID = uuid.MustParse("51A51C00-0000-0000-0000-000000000000")
|
|
|
|
func ResourceID[T Auditable](tgt T) uuid.UUID {
|
|
switch typed := any(tgt).(type) {
|
|
case database.Template:
|
|
return typed.ID
|
|
case database.TemplateVersion:
|
|
return typed.ID
|
|
case database.User:
|
|
return typed.ID
|
|
case database.WorkspaceTable:
|
|
return typed.ID
|
|
case database.WorkspaceBuild:
|
|
return typed.ID
|
|
case database.GitSSHKey:
|
|
return typed.UserID
|
|
case database.AuditableGroup:
|
|
return typed.Group.ID
|
|
case database.APIKey:
|
|
return typed.UserID
|
|
case database.License:
|
|
return typed.UUID
|
|
case database.WorkspaceProxy:
|
|
return typed.ID
|
|
case database.AuditOAuthConvertState:
|
|
// The merge state is for the given user
|
|
return typed.UserID
|
|
case database.HealthSettings:
|
|
// Artificial ID for auditing purposes
|
|
return typed.ID
|
|
case database.NotificationsSettings:
|
|
// Artificial ID for auditing purposes
|
|
return typed.ID
|
|
case database.PrebuildsSettings:
|
|
// Artificial ID for auditing purposes
|
|
return typed.ID
|
|
case database.OAuth2ProviderSettings:
|
|
// Artificial ID for auditing purposes
|
|
return typed.ID
|
|
case database.OAuth2ProviderApp:
|
|
return typed.ID
|
|
case database.OAuth2ProviderAppSecret:
|
|
return typed.ID
|
|
case database.CustomRole:
|
|
return typed.ID
|
|
case database.AuditableOrganizationMember:
|
|
return typed.UserID
|
|
case database.Organization:
|
|
return typed.ID
|
|
case database.NotificationTemplate:
|
|
return typed.ID
|
|
case idpsync.OrganizationSyncSettings:
|
|
return noID // Deployment all uses the same org sync settings
|
|
case idpsync.GroupSyncSettings:
|
|
return noID // Org field on audit log has org id
|
|
case idpsync.RoleSyncSettings:
|
|
return noID // Org field on audit log has org id
|
|
case database.TaskTable:
|
|
return typed.ID
|
|
case database.AISeatState:
|
|
return typed.UserID
|
|
case database.AIProvider:
|
|
return typed.ID
|
|
case database.AIProviderKey:
|
|
return typed.ID
|
|
case database.AIGatewayKey:
|
|
return typed.ID
|
|
case database.AuditableGroupAIBudget:
|
|
return typed.GroupID
|
|
case database.AuditableUserAIBudgetOverride:
|
|
return typed.UserID
|
|
case database.Chat:
|
|
return typed.ID
|
|
case database.UserSecret:
|
|
return typed.ID
|
|
case database.UserSkill:
|
|
return typed.ID
|
|
default:
|
|
panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt))
|
|
}
|
|
}
|
|
|
|
func ResourceType[T Auditable](tgt T) database.ResourceType {
|
|
switch typed := any(tgt).(type) {
|
|
case database.Template:
|
|
return database.ResourceTypeTemplate
|
|
case database.TemplateVersion:
|
|
return database.ResourceTypeTemplateVersion
|
|
case database.User:
|
|
return database.ResourceTypeUser
|
|
case database.WorkspaceTable:
|
|
return database.ResourceTypeWorkspace
|
|
case database.WorkspaceBuild:
|
|
return database.ResourceTypeWorkspaceBuild
|
|
case database.GitSSHKey:
|
|
return database.ResourceTypeGitSshKey
|
|
case database.AuditableGroup:
|
|
return database.ResourceTypeGroup
|
|
case database.APIKey:
|
|
return database.ResourceTypeApiKey
|
|
case database.License:
|
|
return database.ResourceTypeLicense
|
|
case database.WorkspaceProxy:
|
|
return database.ResourceTypeWorkspaceProxy
|
|
case database.AuditOAuthConvertState:
|
|
return database.ResourceTypeConvertLogin
|
|
case database.HealthSettings:
|
|
return database.ResourceTypeHealthSettings
|
|
case database.NotificationsSettings:
|
|
return database.ResourceTypeNotificationsSettings
|
|
case database.PrebuildsSettings:
|
|
return database.ResourceTypePrebuildsSettings
|
|
case database.OAuth2ProviderSettings:
|
|
return database.ResourceTypeOauth2ProviderSettings
|
|
case database.OAuth2ProviderApp:
|
|
return database.ResourceTypeOauth2ProviderApp
|
|
case database.OAuth2ProviderAppSecret:
|
|
return database.ResourceTypeOauth2ProviderAppSecret
|
|
case database.CustomRole:
|
|
return database.ResourceTypeCustomRole
|
|
case database.AuditableOrganizationMember:
|
|
return database.ResourceTypeOrganizationMember
|
|
case database.Organization:
|
|
return database.ResourceTypeOrganization
|
|
case database.NotificationTemplate:
|
|
return database.ResourceTypeNotificationTemplate
|
|
case idpsync.OrganizationSyncSettings:
|
|
return database.ResourceTypeIdpSyncSettingsOrganization
|
|
case idpsync.RoleSyncSettings:
|
|
return database.ResourceTypeIdpSyncSettingsRole
|
|
case idpsync.GroupSyncSettings:
|
|
return database.ResourceTypeIdpSyncSettingsGroup
|
|
case database.TaskTable:
|
|
return database.ResourceTypeTask
|
|
case database.AISeatState:
|
|
return database.ResourceTypeAISeat
|
|
case database.AIProvider:
|
|
return database.ResourceTypeAIProvider
|
|
case database.AIProviderKey:
|
|
return database.ResourceTypeAIProviderKey
|
|
case database.AIGatewayKey:
|
|
return database.ResourceTypeAIGatewayKey
|
|
case database.AuditableGroupAIBudget:
|
|
return database.ResourceTypeGroupAIBudget
|
|
case database.AuditableUserAIBudgetOverride:
|
|
return database.ResourceTypeUserAIBudgetOverride
|
|
case database.Chat:
|
|
return database.ResourceTypeChat
|
|
case database.UserSecret:
|
|
return database.ResourceTypeUserSecret
|
|
case database.UserSkill:
|
|
return database.ResourceTypeUserSkill
|
|
default:
|
|
panic(fmt.Sprintf("unknown resource %T for ResourceType", typed))
|
|
}
|
|
}
|
|
|
|
// ResourceRequiresOrgID will ensure given resources are always audited with an
|
|
// organization ID.
|
|
func ResourceRequiresOrgID[T Auditable]() bool {
|
|
var tgt T
|
|
switch any(tgt).(type) {
|
|
case database.Template, database.TemplateVersion:
|
|
return true
|
|
case database.WorkspaceTable, database.WorkspaceBuild:
|
|
return true
|
|
case database.AuditableGroup:
|
|
return true
|
|
case database.User:
|
|
return false
|
|
case database.GitSSHKey:
|
|
return false
|
|
case database.APIKey:
|
|
return false
|
|
case database.License:
|
|
return false
|
|
case database.WorkspaceProxy:
|
|
return false
|
|
case database.AuditOAuthConvertState:
|
|
// The merge state is for the given user
|
|
return false
|
|
case database.HealthSettings:
|
|
// Artificial ID for auditing purposes
|
|
return false
|
|
case database.NotificationsSettings:
|
|
// Artificial ID for auditing purposes
|
|
return false
|
|
case database.PrebuildsSettings:
|
|
// Artificial ID for auditing purposes
|
|
return false
|
|
case database.OAuth2ProviderSettings:
|
|
// Artificial ID for auditing purposes
|
|
return false
|
|
case database.OAuth2ProviderApp:
|
|
return false
|
|
case database.OAuth2ProviderAppSecret:
|
|
return false
|
|
case database.CustomRole:
|
|
return true
|
|
case database.AuditableOrganizationMember:
|
|
return true
|
|
case database.Organization:
|
|
return true
|
|
case database.NotificationTemplate:
|
|
return false
|
|
case idpsync.OrganizationSyncSettings:
|
|
return false
|
|
case idpsync.GroupSyncSettings:
|
|
return true
|
|
case idpsync.RoleSyncSettings:
|
|
return true
|
|
case database.TaskTable:
|
|
return true
|
|
case database.AISeatState:
|
|
return false
|
|
case database.AIProvider:
|
|
// AI providers are deployment-scoped, not org-scoped.
|
|
return false
|
|
case database.AIProviderKey:
|
|
// AI provider keys inherit the deployment scope of their parent
|
|
// provider.
|
|
return false
|
|
case database.AIGatewayKey:
|
|
// AI Gateway keys are deployment-scoped, not org-scoped.
|
|
return false
|
|
case database.AuditableGroupAIBudget:
|
|
// Group AI budgets are org-scoped through their parent group.
|
|
return true
|
|
case database.AuditableUserAIBudgetOverride:
|
|
// User AI budget overrides are org-scoped through their
|
|
// attributed group.
|
|
return true
|
|
case database.Chat:
|
|
// Chats always have a non-null organization_id (since
|
|
// migration 000467).
|
|
return true
|
|
case database.UserSecret:
|
|
// User secrets are global to the user across organizations.
|
|
return false
|
|
case database.UserSkill:
|
|
// User skills are global to the user across organizations.
|
|
return false
|
|
default:
|
|
panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt))
|
|
}
|
|
}
|
|
|
|
// requireOrgID will either panic (in unit tests) or log an error (in production)
|
|
// if the given resource requires an organization ID and the provided ID is nil.
|
|
func requireOrgID[T Auditable](ctx context.Context, id uuid.UUID, log slog.Logger) uuid.UUID {
|
|
if ResourceRequiresOrgID[T]() && id == uuid.Nil {
|
|
var tgt T
|
|
resourceName := fmt.Sprintf("%T", tgt)
|
|
if flag.Lookup("test.v") != nil {
|
|
// In unit tests we panic to fail the tests
|
|
panic(fmt.Sprintf("missing required organization ID for resource %q", resourceName))
|
|
}
|
|
log.Error(ctx, "missing required organization ID for resource in audit log",
|
|
slog.F("resource", resourceName),
|
|
)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// InitRequestWithCancel returns a commit function with a boolean arg.
|
|
// If the arg is false, future calls to commit() will not create an audit log
|
|
// entry.
|
|
func InitRequestWithCancel[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request[T], func(commit bool)) {
|
|
req, commitF := InitRequest[T](w, p)
|
|
canceled := false
|
|
return req, func(commit bool) {
|
|
// Once 'commit=false' is called, block
|
|
// any future commit attempts.
|
|
if !commit {
|
|
canceled = true
|
|
return
|
|
}
|
|
// If it was ever canceled, block any commits
|
|
if !canceled {
|
|
commitF()
|
|
}
|
|
}
|
|
}
|
|
|
|
// InitRequest initializes an audit log for a request. It returns a function
|
|
// that should be deferred, causing the audit log to be committed when the
|
|
// handler returns.
|
|
func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request[T], func()) {
|
|
sw, ok := w.(*tracing.StatusWriter)
|
|
if !ok {
|
|
panic("dev error: http.ResponseWriter is not *tracing.StatusWriter")
|
|
}
|
|
|
|
req := &Request[T]{
|
|
params: p,
|
|
}
|
|
|
|
return req, func() {
|
|
ctx := context.Background()
|
|
logCtx := p.Request.Context()
|
|
|
|
// If no resources were provided, there's nothing we can audit.
|
|
if ResourceID(req.Old) == uuid.Nil && ResourceID(req.New) == uuid.Nil {
|
|
// If the request action is a login or logout, we always want to audit it even if
|
|
// there is no diff. This is so we can capture events where an API Key is never created
|
|
// because a known user fails to login.
|
|
if req.params.Action != database.AuditActionLogin && req.params.Action != database.AuditActionLogout {
|
|
return
|
|
}
|
|
}
|
|
|
|
diffRaw := []byte("{}")
|
|
// Only generate diffs if the request succeeded
|
|
// and only if we aren't auditing authentication actions
|
|
if sw.Status < 400 &&
|
|
req.params.Action != database.AuditActionLogin && req.params.Action != database.AuditActionLogout {
|
|
diff := Diff(p.Audit, req.Old, req.New)
|
|
|
|
var err error
|
|
diffRaw, err = json.Marshal(diff)
|
|
if err != nil {
|
|
p.Log.Warn(logCtx, "marshal diff", slog.Error(err))
|
|
diffRaw = []byte("{}")
|
|
}
|
|
}
|
|
|
|
additionalFieldsRaw := json.RawMessage("{}")
|
|
|
|
if p.AdditionalFields != nil {
|
|
data, err := json.Marshal(p.AdditionalFields)
|
|
if err != nil {
|
|
p.Log.Warn(logCtx, "marshal additional fields", slog.Error(err))
|
|
} else {
|
|
additionalFieldsRaw = json.RawMessage(data)
|
|
}
|
|
}
|
|
|
|
var userID uuid.UUID
|
|
key, ok := httpmw.APIKeyOptional(p.Request)
|
|
switch {
|
|
case ok:
|
|
userID = key.UserID
|
|
case req.UserID != uuid.Nil:
|
|
userID = req.UserID
|
|
default:
|
|
// if we do not have a user associated with the audit action
|
|
// we do not want to audit
|
|
// (this pertains to logins; we don't want to capture non-user login attempts)
|
|
return
|
|
}
|
|
|
|
action := p.Action
|
|
if req.Action != "" {
|
|
action = req.Action
|
|
}
|
|
|
|
ip := database.ParseIP(p.Request.RemoteAddr)
|
|
auditLog := database.AuditLog{
|
|
ID: uuid.New(),
|
|
Time: dbtime.Now(),
|
|
UserID: userID,
|
|
Ip: ip,
|
|
UserAgent: sql.NullString{String: p.Request.UserAgent(), Valid: true},
|
|
ResourceType: either(req.Old, req.New, ResourceType[T], req.params.Action),
|
|
ResourceID: either(req.Old, req.New, ResourceID[T], req.params.Action),
|
|
ResourceTarget: either(req.Old, req.New, ResourceTarget[T], req.params.Action),
|
|
Action: action,
|
|
Diff: diffRaw,
|
|
// #nosec G115 - Safe conversion as HTTP status code is expected to be within int32 range (typically 100-599)
|
|
StatusCode: int32(sw.Status),
|
|
RequestID: httpmw.RequestID(p.Request),
|
|
AdditionalFields: additionalFieldsRaw,
|
|
OrganizationID: requireOrgID[T](logCtx, p.OrganizationID, p.Log),
|
|
}
|
|
err := p.Audit.Export(ctx, auditLog)
|
|
if err != nil {
|
|
p.Log.Error(logCtx, "export audit log",
|
|
slog.F("audit_log", auditLog),
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// BackgroundAudit creates an audit log for a background event.
|
|
// The audit log is committed upon invocation.
|
|
func BackgroundAudit[T Auditable](ctx context.Context, p *BackgroundAuditParams[T]) {
|
|
ip := database.ParseIP(p.IP)
|
|
|
|
diff := Diff(p.Audit, p.Old, p.New)
|
|
var err error
|
|
diffRaw, err := json.Marshal(diff)
|
|
if err != nil {
|
|
p.Log.Warn(ctx, "marshal diff", slog.Error(err))
|
|
diffRaw = []byte("{}")
|
|
}
|
|
|
|
if p.Time.IsZero() {
|
|
p.Time = dbtime.Now()
|
|
} else {
|
|
// NOTE(mafredri): dbtime.Time does not currently enforce UTC.
|
|
p.Time = dbtime.Time(p.Time.In(time.UTC))
|
|
}
|
|
if p.AdditionalFields == nil {
|
|
p.AdditionalFields = json.RawMessage("{}")
|
|
}
|
|
|
|
auditLog := database.AuditLog{
|
|
ID: uuid.New(),
|
|
Time: p.Time,
|
|
UserID: p.UserID,
|
|
OrganizationID: requireOrgID[T](ctx, p.OrganizationID, p.Log),
|
|
Ip: ip,
|
|
UserAgent: sql.NullString{Valid: p.UserAgent != "", String: p.UserAgent},
|
|
ResourceType: either(p.Old, p.New, ResourceType[T], p.Action),
|
|
ResourceID: either(p.Old, p.New, ResourceID[T], p.Action),
|
|
ResourceTarget: either(p.Old, p.New, ResourceTarget[T], p.Action),
|
|
Action: p.Action,
|
|
Diff: diffRaw,
|
|
// #nosec G115 - Safe conversion as HTTP status code is expected to be within int32 range (typically 100-599)
|
|
StatusCode: int32(p.Status),
|
|
RequestID: p.RequestID,
|
|
AdditionalFields: p.AdditionalFields,
|
|
}
|
|
err = p.Audit.Export(ctx, auditLog)
|
|
if err != nil {
|
|
p.Log.Error(ctx, "export audit log",
|
|
slog.F("audit_log", auditLog),
|
|
slog.Error(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
type WorkspaceBuildBaggage struct {
|
|
IP string
|
|
}
|
|
|
|
func (b WorkspaceBuildBaggage) Props() ([]baggage.Property, error) {
|
|
ipProp, err := baggage.NewKeyValueProperty("ip", b.IP)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("create ip kv property: %w", err)
|
|
}
|
|
|
|
return []baggage.Property{ipProp}, nil
|
|
}
|
|
|
|
func WorkspaceBuildBaggageFromRequest(r *http.Request) WorkspaceBuildBaggage {
|
|
return WorkspaceBuildBaggage{IP: r.RemoteAddr}
|
|
}
|
|
|
|
type Baggage interface {
|
|
Props() ([]baggage.Property, error)
|
|
}
|
|
|
|
func BaggageToContext(ctx context.Context, d Baggage) (context.Context, error) {
|
|
props, err := d.Props()
|
|
if err != nil {
|
|
return ctx, xerrors.Errorf("create baggage properties: %w", err)
|
|
}
|
|
|
|
m, err := baggage.NewMember("audit", "baggage", props...)
|
|
if err != nil {
|
|
return ctx, xerrors.Errorf("create new baggage member: %w", err)
|
|
}
|
|
|
|
b, err := baggage.New(m)
|
|
if err != nil {
|
|
return ctx, xerrors.Errorf("create new baggage carrier: %w", err)
|
|
}
|
|
|
|
return baggage.ContextWithBaggage(ctx, b), nil
|
|
}
|
|
|
|
func BaggageFromContext(ctx context.Context) WorkspaceBuildBaggage {
|
|
d := WorkspaceBuildBaggage{}
|
|
b := baggage.FromContext(ctx)
|
|
props := b.Member("audit").Properties()
|
|
for _, prop := range props {
|
|
switch prop.Key() {
|
|
case "ip":
|
|
d.IP, _ = prop.Value()
|
|
default:
|
|
}
|
|
}
|
|
|
|
return d
|
|
}
|
|
|
|
func either[T Auditable, R any](old, newVal T, fn func(T) R, auditAction database.AuditAction) R {
|
|
switch {
|
|
case ResourceID(newVal) != uuid.Nil:
|
|
return fn(newVal)
|
|
case ResourceID(old) != uuid.Nil:
|
|
return fn(old)
|
|
case auditAction == database.AuditActionLogin || auditAction == database.AuditActionLogout:
|
|
// If the request action is a login or logout, we always want to audit it even if
|
|
// there is no diff. See the comment in audit.InitRequest for more detail.
|
|
return fn(old)
|
|
default:
|
|
panic("both old and new are nil")
|
|
}
|
|
}
|