diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index bc7ecbda7ce3f..df33e539ab4a0 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -24305,7 +24305,8 @@ const docTemplate = `{ "user_ai_budget_override", "chat", "user_secret", - "user_skill" + "user_skill", + "chat_instruction_settings" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -24343,7 +24344,8 @@ const docTemplate = `{ "ResourceTypeUserAIBudgetOverride", "ResourceTypeChat", "ResourceTypeUserSecret", - "ResourceTypeUserSkill" + "ResourceTypeUserSkill", + "ResourceTypeChatInstructionSettings" ] }, "codersdk.Response": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 638c27ec248f2..c052f8d023bf2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -22287,7 +22287,8 @@ "user_ai_budget_override", "chat", "user_secret", - "user_skill" + "user_skill", + "chat_instruction_settings" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -22325,7 +22326,8 @@ "ResourceTypeUserAIBudgetOverride", "ResourceTypeChat", "ResourceTypeUserSecret", - "ResourceTypeUserSkill" + "ResourceTypeUserSkill", + "ResourceTypeChatInstructionSettings" ] }, "codersdk.Response": { diff --git a/coderd/audit/audit.go b/coderd/audit/audit.go index 2b3a34d3a8f51..2b1a60dc67cbc 100644 --- a/coderd/audit/audit.go +++ b/coderd/audit/audit.go @@ -42,9 +42,19 @@ func NewMock() *MockAuditor { return &MockAuditor{} } +// NewMockWithDiffFn returns a MockAuditor whose entries carry diffs computed +// by the supplied function instead of the default empty diff. Tests use it to +// pin the Old/New pair a handler captured, which the empty diff cannot +// express. The function is test-supplied comparison logic; the mock never +// calls the production differ. +func NewMockWithDiffFn(fn func(old, newVal any) Map) *MockAuditor { + return &MockAuditor{diffFn: fn} +} + type MockAuditor struct { mutex sync.Mutex auditLogs []database.AuditLog + diffFn func(old, newVal any) Map } // ResetLogs removes all audit logs from the mock auditor. @@ -70,8 +80,13 @@ func (a *MockAuditor) Export(_ context.Context, alog database.AuditLog) error { return nil } -func (*MockAuditor) diff(any, any) Map { - return Map{} +func (a *MockAuditor) diff(old, newVal any) Map { + a.mutex.Lock() + defer a.mutex.Unlock() + if a.diffFn == nil { + return Map{} + } + return a.diffFn(old, newVal) } // Contains returns true if, for each non-zero-valued field in expected, diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 374f74dd7ab26..97105c24d54a5 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -42,7 +42,8 @@ type Auditable interface { database.AuditableGroupAIBudget | database.AuditableUserAIBudgetOverride | database.UserSecret | - database.UserSkill + database.UserSkill | + database.ChatInstructionSettings } // Map is a map of changed fields in an audited resource. It maps field names to diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 4b213968bd3e8..9aaafb1e2ccdb 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -158,6 +158,8 @@ func ResourceTarget[T Auditable](tgt T) string { return typed.Name case database.UserSkill: return typed.Name + case database.ChatInstructionSettings: + return typed.Name default: panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt)) } @@ -168,6 +170,22 @@ func ResourceTarget[T Auditable](tgt T) string { // 51A51C = "Static" var noID = uuid.MustParse("51A51C00-0000-0000-0000-000000000000") +// Fixed IDs for the two chat instruction settings. History-by-setting works +// only if every change to one setting carries the same resource ID, so +// unlike the per-write artificial IDs of the other settings singletons, +// these never change. C1A7 = "Chat". +var ( + ChatInstructionSystemPromptID = uuid.MustParse("C1A715C0-0000-0000-0000-000000000001") + ChatInstructionPlanModeID = uuid.MustParse("C1A715C0-0000-0000-0000-000000000002") +) + +// Human-readable targets for the two chat instruction settings, so an audit +// row names the setting it concerns. +const ( + ChatInstructionSystemPromptName = "System prompt" + ChatInstructionPlanModeName = "Plan mode instructions" +) + func ResourceID[T Auditable](tgt T) uuid.UUID { switch typed := any(tgt).(type) { case database.Template: @@ -243,6 +261,9 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { return typed.ID case database.UserSkill: return typed.ID + case database.ChatInstructionSettings: + // Fixed ID per setting; see ChatInstructionSettings IDs. + return typed.ID default: panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt)) } @@ -318,6 +339,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeUserSecret case database.UserSkill: return database.ResourceTypeUserSkill + case database.ChatInstructionSettings: + return database.ResourceTypeChatInstructionSettings default: panic(fmt.Sprintf("unknown resource %T for ResourceType", typed)) } @@ -408,6 +431,9 @@ func ResourceRequiresOrgID[T Auditable]() bool { case database.UserSkill: // User skills are global to the user across organizations. return false + case database.ChatInstructionSettings: + // Deployment settings, not scoped to any organization. + return false default: panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt)) } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 92f2f77b975ba..ee444df91696b 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -598,7 +598,8 @@ CREATE TYPE resource_type AS ENUM ( 'user_skill', 'ai_gateway_key', 'user_ai_budget_override', - 'oauth2_provider_settings' + 'oauth2_provider_settings', + 'chat_instruction_settings' ); CREATE TYPE shareable_workspace_owners AS ENUM ( diff --git a/coderd/database/lock.go b/coderd/database/lock.go index a9830336fedfe..846758891205f 100644 --- a/coderd/database/lock.go +++ b/coderd/database/lock.go @@ -20,6 +20,17 @@ const ( LockIDChatCapacityAdmission ) +// Per-setting advisory lock IDs for the chat instruction settings. These +// derive from the exact site_configs key with GenLockID (FNV-1a 64) instead +// of the sequential LockID* block above, so writers of different settings +// never contend and the IDs cannot collide with any sequentially allocated +// lock ID (different derivation space) or with another subsystem's +// GenLockID output (the key strings are unique to these settings). +var ( + LockIDChatInstructionSystemPrompt = GenLockID("agents_chat_system_prompt") + LockIDChatInstructionPlanMode = GenLockID("agents_chat_plan_mode_instructions") +) + // GenLockID generates a unique and consistent lock ID from a given string. func GenLockID(name string) int64 { hash := fnv.New64() diff --git a/coderd/database/lock_internal_test.go b/coderd/database/lock_internal_test.go new file mode 100644 index 0000000000000..c320841461542 --- /dev/null +++ b/coderd/database/lock_internal_test.go @@ -0,0 +1,50 @@ +package database + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestChatInstructionLockIDsDistinct proves the per-setting advisory lock IDs +// for the chat instruction settings cannot collide with each other or with +// any sequentially allocated LockID* constant. The constants are listed +// explicitly rather than enumerated programmatically (there is no registry of +// iota constants), so a future LockID* addition that collides fails here in +// review, not in a production deadlock. +func TestChatInstructionLockIDsDistinct(t *testing.T) { + t.Parallel() + + generated := map[string]int64{ + "LockIDChatInstructionSystemPrompt": LockIDChatInstructionSystemPrompt, + "LockIDChatInstructionPlanMode": LockIDChatInstructionPlanMode, + } + + sequential := map[string]int64{ + "LockIDDeploymentSetup": LockIDDeploymentSetup, + "LockIDEnterpriseDeploymentSetup": LockIDEnterpriseDeploymentSetup, + "LockIDDBRollup": LockIDDBRollup, + "LockIDDBPurge": LockIDDBPurge, + "LockIDNotificationsReportGenerator": LockIDNotificationsReportGenerator, + "LockIDCryptoKeyRotation": LockIDCryptoKeyRotation, + "LockIDReconcilePrebuilds": LockIDReconcilePrebuilds, + "LockIDReconcileSystemRoles": LockIDReconcileSystemRoles, + "LockIDBoundaryUsageStats": LockIDBoundaryUsageStats, + "LockIDAIProvidersEnvSeed": LockIDAIProvidersEnvSeed, + "LockIDChatModelConfigWrites": LockIDChatModelConfigWrites, + } + + // The two generated IDs are pairwise distinct. + require.NotEqual(t, + LockIDChatInstructionSystemPrompt, + LockIDChatInstructionPlanMode, + "per-setting lock IDs must differ from each other") + + // Neither generated ID collides with any sequential constant. + for name, id := range generated { + for seqName, seqID := range sequential { + require.NotEqualf(t, seqID, id, + "%s (%d) collides with sequential constant %s", name, id, seqName) + } + } +} diff --git a/coderd/database/migrations/000573_audit_chat_instruction_settings.down.sql b/coderd/database/migrations/000573_audit_chat_instruction_settings.down.sql new file mode 100644 index 0000000000000..35020b349fc4e --- /dev/null +++ b/coderd/database/migrations/000573_audit_chat_instruction_settings.down.sql @@ -0,0 +1 @@ +-- No-op, enum values can't be dropped. diff --git a/coderd/database/migrations/000573_audit_chat_instruction_settings.up.sql b/coderd/database/migrations/000573_audit_chat_instruction_settings.up.sql new file mode 100644 index 0000000000000..aa03609c738d1 --- /dev/null +++ b/coderd/database/migrations/000573_audit_chat_instruction_settings.up.sql @@ -0,0 +1,2 @@ +ALTER TYPE resource_type + ADD VALUE IF NOT EXISTS 'chat_instruction_settings'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 22aaada3fb376..cff3b4c6bf446 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3535,6 +3535,7 @@ const ( ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key" ResourceTypeUserAIBudgetOverride ResourceType = "user_ai_budget_override" ResourceTypeOauth2ProviderSettings ResourceType = "oauth2_provider_settings" + ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings" ) func (e *ResourceType) Scan(src interface{}) error { @@ -3609,7 +3610,8 @@ func (e ResourceType) Valid() bool { ResourceTypeUserSkill, ResourceTypeAIGatewayKey, ResourceTypeUserAIBudgetOverride, - ResourceTypeOauth2ProviderSettings: + ResourceTypeOauth2ProviderSettings, + ResourceTypeChatInstructionSettings: return true } return false @@ -3653,6 +3655,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeAIGatewayKey, ResourceTypeUserAIBudgetOverride, ResourceTypeOauth2ProviderSettings, + ResourceTypeChatInstructionSettings, } } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index bf7063bbb670c..39c721ce36023 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -25095,12 +25095,18 @@ SELECT WHERE key = 'agents_chat_system_prompt' AND value != '' ) - ) :: boolean AS include_default_system_prompt + ) :: boolean AS include_default_system_prompt, + EXISTS ( + SELECT 1 + FROM site_configs + WHERE key = 'agents_chat_include_default_system_prompt' + ) :: boolean AS include_default_system_prompt_set ` type GetChatSystemPromptConfigRow struct { - ChatSystemPrompt string `db:"chat_system_prompt" json:"chat_system_prompt"` - IncludeDefaultSystemPrompt bool `db:"include_default_system_prompt" json:"include_default_system_prompt"` + ChatSystemPrompt string `db:"chat_system_prompt" json:"chat_system_prompt"` + IncludeDefaultSystemPrompt bool `db:"include_default_system_prompt" json:"include_default_system_prompt"` + IncludeDefaultSystemPromptSet bool `db:"include_default_system_prompt_set" json:"include_default_system_prompt_set"` } // GetChatSystemPromptConfig returns both chat system prompt settings in a @@ -25111,7 +25117,7 @@ type GetChatSystemPromptConfigRow struct { func (q *sqlQuerier) GetChatSystemPromptConfig(ctx context.Context) (GetChatSystemPromptConfigRow, error) { row := q.db.QueryRowContext(ctx, getChatSystemPromptConfig) var i GetChatSystemPromptConfigRow - err := row.Scan(&i.ChatSystemPrompt, &i.IncludeDefaultSystemPrompt) + err := row.Scan(&i.ChatSystemPrompt, &i.IncludeDefaultSystemPrompt, &i.IncludeDefaultSystemPromptSet) return i, err } diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 5cc902baa748d..a4d9589f7daef 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -175,7 +175,12 @@ SELECT WHERE key = 'agents_chat_system_prompt' AND value != '' ) - ) :: boolean AS include_default_system_prompt; + ) :: boolean AS include_default_system_prompt, + EXISTS ( + SELECT 1 + FROM site_configs + WHERE key = 'agents_chat_include_default_system_prompt' + ) :: boolean AS include_default_system_prompt_set; -- name: UpsertChatSystemPrompt :exec INSERT INTO site_configs (key, value) VALUES ('agents_chat_system_prompt', $1) diff --git a/coderd/database/types.go b/coderd/database/types.go index 68ad4b5a40f97..cfa518c7ca2cb 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -49,6 +49,27 @@ type OAuth2ProviderSettings struct { DynamicClientRegistrationEnabled bool `db:"dynamic_client_registration_enabled" json:"dynamic_client_registration_enabled"` } +// ChatInstructionSettings is the auditable shape of the deployment-wide +// chat instruction configuration, stored across the +// agents_chat_system_prompt, agents_chat_include_default_system_prompt and +// agents_chat_plan_mode_instructions site_configs keys. Both the +// system-prompt and plan-mode-instructions endpoints audit this one type; +// each populates only the fields its endpoint can change. +type ChatInstructionSettings struct { + ID uuid.UUID `db:"id" json:"id"` + // Name identifies which setting an audit row concerns (e.g. "System + // prompt"). It is ignored in diffs and set identically on Old and New. + Name string `db:"name" json:"name"` + SystemPrompt string `db:"system_prompt" json:"system_prompt"` + // IncludeDefaultSystemPromptSet records whether the override row + // exists, not only its effective value: writing explicit false over a + // legacy absent row does not move the effective value but changes + // future behavior, so presence must enter the diff. + IncludeDefaultSystemPromptSet bool `db:"include_default_system_prompt_set" json:"include_default_system_prompt_set"` + IncludeDefaultSystemPrompt bool `db:"include_default_system_prompt" json:"include_default_system_prompt"` + PlanModeInstructions string `db:"plan_mode_instructions" json:"plan_mode_instructions"` +} + type Actions []policy.Action func (a *Actions) Scan(src interface{}) error { diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index f98a22d11f3cf..0e9026a8fc80f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4546,12 +4546,38 @@ func (api *API) getChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { }) } +// chatInstructionSettingsLockTimeout bounds how long a request waits for the +// per-setting advisory lock. The only other holders are sibling requests +// holding it for a single upsert, so a short bound cannot strand a waiter: +// on expiry the request falls back to the unaudited write path instead of +// hanging until the client deadline. +const chatInstructionSettingsLockTimeout = 5 * time.Second + func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + + // Identity is assigned before the authorization check so a denied PUT + // records the attempt with status 403 and an empty diff. The body is + // never read before authorization, so no request content reaches that + // row. + aReq, commitAudit := audit.InitRequestWithCancel[database.ChatInstructionSettings](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + defer commitAudit(true) + aReq.Old = database.ChatInstructionSettings{ + ID: audit.ChatInstructionSystemPromptID, + Name: audit.ChatInstructionSystemPromptName, + } + aReq.New = aReq.Old + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { httpapi.Forbidden(rw) return } + // Cap the raw request body to prevent excessive memory use from // payloads padded with invisible characters that sanitize away. r.Body = http.MaxBytesReader(rw, r.Body, int64(2*maxSystemPromptLenBytes)) @@ -4569,7 +4595,40 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { }) return } + + var ( + noChange bool + oldCaptured bool + oldReadErr error + lockErr error + ) + // The per-setting advisory lock serializes the audit change-detection + // with the write: two concurrent identical PUTs both still succeed, + // but the second transaction's comparison sees the first's committed + // state and reports no change. The lock wait is bounded so a waiter + // cannot hang past the client's deadline. + lockCtx, lockCancel := context.WithTimeout(ctx, chatInstructionSettingsLockTimeout) + defer lockCancel() err := api.Database.InTx(func(tx database.Store) error { + if err := tx.AcquireLock(lockCtx, database.LockIDChatInstructionSystemPrompt); err != nil { + lockErr = err + return xerrors.Errorf("acquire chat instruction setting write lock: %w", err) + } + + // Old capture is best effort: it runs after the lock but before + // the write, so abandoning the transaction on its error costs + // nothing and the handler writes directly below with no entry. + // The lock keeps the captured baseline serialized with the write. + oldConfig, oldErr := tx.GetChatSystemPromptConfig(ctx) + if oldErr != nil { + oldReadErr = oldErr + return oldErr + } + oldCaptured = true + aReq.Old.SystemPrompt = oldConfig.ChatSystemPrompt + aReq.Old.IncludeDefaultSystemPromptSet = oldConfig.IncludeDefaultSystemPromptSet + aReq.Old.IncludeDefaultSystemPrompt = oldConfig.IncludeDefaultSystemPrompt + if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil { return err } @@ -4579,17 +4638,83 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { // avoiding a backward-compatibility regression for older clients // that only send system_prompt. if req.IncludeDefaultSystemPrompt != nil { - return tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt) + if err := tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt); err != nil { + return err + } } + + // Derive New from what was written rather than re-reading: the + // upserts store $1 verbatim, so the stored prompt is the request + // value, and the effective include-default flag follows + // GetChatSystemPromptConfig's rule from the written toggle row + // and the written prompt. A post-write read would fail on + // query-local context cancellation even after a successful + // commit, silently discarding a change the client made. + newIncludeSet := oldConfig.IncludeDefaultSystemPromptSet + newIncludeValue := oldConfig.IncludeDefaultSystemPrompt + if req.IncludeDefaultSystemPrompt != nil { + newIncludeSet = true + newIncludeValue = *req.IncludeDefaultSystemPrompt + } + if !newIncludeSet { + // Legacy fallback: a non-empty custom prompt implies opting + // out; otherwise the setting defaults to true. + newIncludeValue = sanitizedPrompt == "" + } + aReq.New.SystemPrompt = sanitizedPrompt + aReq.New.IncludeDefaultSystemPromptSet = newIncludeSet + aReq.New.IncludeDefaultSystemPrompt = newIncludeValue + noChange = aReq.New.SystemPrompt == aReq.Old.SystemPrompt && + aReq.New.IncludeDefaultSystemPromptSet == aReq.Old.IncludeDefaultSystemPromptSet && + aReq.New.IncludeDefaultSystemPrompt == aReq.Old.IncludeDefaultSystemPrompt return nil }, nil) if err != nil { + auditSetupErr := lockErr + if auditSetupErr == nil { + auditSetupErr = oldReadErr + } + if auditSetupErr != nil { + // The audit-added lock wait or Old capture failed before + // anything was written, so the transaction was abandoned at + // no cost: write directly exactly as main does, emit no + // entry, and warn. + api.Logger.Warn(ctx, "audit change detection failed, writing chat system prompt without an audit entry", + slog.Error(auditSetupErr)) + commitAudit(false) + if mainErr := api.Database.InTx(func(tx database.Store) error { + if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil { + return err + } + if req.IncludeDefaultSystemPrompt != nil { + return tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt) + } + return nil + }, nil); mainErr != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating chat system prompt configuration.", + Detail: mainErr.Error(), + }) + return + } + rw.WriteHeader(http.StatusNoContent) + return + } + // This endpoint was transactional on main, so begin, write, + // commit and rollback errors all surface exactly as main's. The + // advisory lock is audit-added, so its failure falls back above + // instead of surfacing. httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error updating chat system prompt configuration.", Detail: err.Error(), }) return } + if oldCaptured && noChange { + // Stage the no-op decision until after the transaction commits, + // so a commit failure cannot suppress an attempt row. + commitAudit(false) + } rw.WriteHeader(http.StatusNoContent) } @@ -4620,6 +4745,24 @@ func (api *API) getChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ // EXPERIMENTAL: this endpoint is experimental and is subject to change. func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + + // Identity is assigned before the authorization check so a denied PUT + // records the attempt with status 403 and an empty diff. The body is + // never read before authorization, so no request content reaches that + // row. + aReq, commitAudit := audit.InitRequestWithCancel[database.ChatInstructionSettings](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + defer commitAudit(true) + aReq.Old = database.ChatInstructionSettings{ + ID: audit.ChatInstructionPlanModeID, + Name: audit.ChatInstructionPlanModeName, + } + aReq.New = aReq.Old + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { httpapi.Forbidden(rw) return @@ -4643,14 +4786,94 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ return } - if err := api.Database.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error updating plan mode instructions.", - Detail: err.Error(), - }) - return - } + // This endpoint was not transactional on main, so the audited + // transaction's lock, begin, commit and rollback failures run the + // direct write instead, matching main's non-transactional behavior. + // Only a failure of the write itself surfaces the transaction error + // (the raw write error, as main produced). + var ( + noChange bool + oldCaptured bool + oldReadErr error + ) + lockCtx, lockCancel := context.WithTimeout(ctx, chatInstructionSettingsLockTimeout) + defer lockCancel() + var writeErr error + err := api.Database.InTx(func(tx database.Store) error { + if err := tx.AcquireLock(lockCtx, database.LockIDChatInstructionPlanMode); err != nil { + return xerrors.Errorf("acquire chat instruction setting write lock: %w", err) + } + + // Old capture is best effort: it runs after the lock but before + // the write, so abandoning the transaction on its error costs + // nothing and the handler writes directly below with no entry; + // see putChatSystemPrompt for why New is derived from the write. + oldInstructions, oldErr := tx.GetChatPlanModeInstructions(ctx) + if oldErr != nil { + oldReadErr = oldErr + return oldErr + } + oldCaptured = true + aReq.Old.PlanModeInstructions = oldInstructions + if err := tx.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil { + writeErr = err + return err + } + aReq.New.PlanModeInstructions = sanitizedInstructions + noChange = aReq.New.PlanModeInstructions == aReq.Old.PlanModeInstructions + return nil + }, nil) + if err != nil { + // Log the full InTx error first: the response below derives from + // the write error alone, so a rollback failure layered on it + // would otherwise vanish from response, audit row and logs + // simultaneously. + api.Logger.Warn(ctx, "plan mode instructions update transaction failed", + slog.Error(err)) + if oldReadErr != nil { + // The Old capture failed before anything was written: fall + // through to the direct write below with no entry. + oldCaptured = false + } else if writeErr != nil { + // The write itself failed: respond with the raw error + // exactly as the endpoint did before the audit wiring + // existed. + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating plan mode instructions.", + Detail: writeErr.Error(), + }) + return + } + // Only the audit-added transaction machinery failed (lock, + // begin, commit, rollback, or the Old read), not the write. + // Main's non-transactional behavior is authoritative: run the + // direct upsert and derive the response from that. + if mainErr := api.Database.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); mainErr != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating plan mode instructions.", + Detail: mainErr.Error(), + }) + return + } + if !oldCaptured { + // The lock wait or Old capture failed, so the write landed + // through the direct path with no baseline: no entry, warn, + // and finish with the success response. A commit failure + // keeps oldCaptured true and deliberately falls through so + // the attempt row with its real diff survives. + api.Logger.Warn(ctx, "audit change detection failed, writing plan mode instructions without an audit entry", + slog.Error(err)) + commitAudit(false) + rw.WriteHeader(http.StatusNoContent) + return + } + } + if oldCaptured && noChange { + // Stage the no-op decision until after the transaction commits, + // so a commit failure cannot suppress an attempt row. + commitAudit(false) + } rw.WriteHeader(http.StatusNoContent) } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index e57d4f089b04e..1d2111b4cefa5 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -16,6 +16,7 @@ import ( "slices" "strconv" "strings" + "sync" "sync/atomic" "testing" "time" @@ -24,6 +25,7 @@ import ( "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sqlc-dev/pqtype" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" "golang.org/x/xerrors" @@ -200,12 +202,42 @@ func findUserMessage(t testing.TB, messages []database.ChatMessage) database.Cha return messages[idx] } +// failNextChatSystemPromptStore lets a test force single chat system prompt +// queries to fail. One-shot flags disarm when the forced failure fires, and +// armed counters fail every matching read until the armed count reaches zero, +// so failures swallowed by best-effort audit captures do not leak into later +// reads. Flags and counters are shared across InTx wrappers. type failNextChatSystemPromptStore struct { database.Store - failNextGetChatIncludeDefaultSystemPrompt atomic.Bool - failNextGetChatSystemPromptConfig atomic.Bool - failNextUpsertChatIncludeDefaultSystemPrompt atomic.Bool + failNextGetChatIncludeDefaultSystemPrompt *atomic.Bool + failNextGetChatSystemPromptConfig *atomic.Bool + armedGetChatSystemPromptConfigFailures *atomic.Int64 + getChatSystemPromptConfigCallsBeforeFailure *atomic.Int64 + failNextUpsertChatIncludeDefaultSystemPrompt *atomic.Bool +} + +func newFailNextChatSystemPromptStore(store database.Store) *failNextChatSystemPromptStore { + return &failNextChatSystemPromptStore{ + Store: store, + failNextGetChatIncludeDefaultSystemPrompt: &atomic.Bool{}, + failNextGetChatSystemPromptConfig: &atomic.Bool{}, + armedGetChatSystemPromptConfigFailures: &atomic.Int64{}, + getChatSystemPromptConfigCallsBeforeFailure: &atomic.Int64{}, + failNextUpsertChatIncludeDefaultSystemPrompt: &atomic.Bool{}, + } +} + +func (s *failNextChatSystemPromptStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return function(&failNextChatSystemPromptStore{ + Store: tx, + failNextGetChatSystemPromptConfig: s.failNextGetChatSystemPromptConfig, + armedGetChatSystemPromptConfigFailures: s.armedGetChatSystemPromptConfigFailures, + getChatSystemPromptConfigCallsBeforeFailure: s.getChatSystemPromptConfigCallsBeforeFailure, + failNextUpsertChatIncludeDefaultSystemPrompt: s.failNextUpsertChatIncludeDefaultSystemPrompt, + }) + }, txOpts) } func (s *failNextChatSystemPromptStore) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) { @@ -226,9 +258,191 @@ func (s *failNextChatSystemPromptStore) GetChatSystemPromptConfig(ctx context.Co if s.failNextGetChatSystemPromptConfig.CompareAndSwap(true, false) { return database.GetChatSystemPromptConfigRow{}, stderrors.New("forced chat system prompt configuration read failure") } + // Armed failures fire on every read until the armed count is spent. + // Best-effort audit captures swallow these errors without retrying, + // which is why a one-shot flag is not enough: the failure would stay + // latent and fire on a later, unrelated read. + if s.armedGetChatSystemPromptConfigFailures != nil && s.armedGetChatSystemPromptConfigFailures.Load() > 0 { + // When a skip count is set, the first N reads succeed so failures + // can be aimed at a specific read, e.g. the New re-read after the + // Old capture. + if s.getChatSystemPromptConfigCallsBeforeFailure != nil && s.getChatSystemPromptConfigCallsBeforeFailure.Load() > 0 { + s.getChatSystemPromptConfigCallsBeforeFailure.Add(-1) + return s.Store.GetChatSystemPromptConfig(ctx) + } + s.armedGetChatSystemPromptConfigFailures.Add(-1) + return database.GetChatSystemPromptConfigRow{}, stderrors.New("forced chat system prompt configuration read failure") + } return s.Store.GetChatSystemPromptConfig(ctx) } +// failNextUpsertChatSystemPromptStore lets a test force the system-prompt +// upsert to fail once, sharing its failure state across InTx wrappers. +type failNextUpsertChatSystemPromptStore struct { + database.Store + + failNextUpsertChatSystemPrompt *atomic.Bool +} + +func newFailNextUpsertChatSystemPromptStore(store database.Store) *failNextUpsertChatSystemPromptStore { + return &failNextUpsertChatSystemPromptStore{ + Store: store, + failNextUpsertChatSystemPrompt: &atomic.Bool{}, + } +} + +func (s *failNextUpsertChatSystemPromptStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return function(&failNextUpsertChatSystemPromptStore{ + Store: tx, + failNextUpsertChatSystemPrompt: s.failNextUpsertChatSystemPrompt, + }) + }, txOpts) +} + +func (s *failNextUpsertChatSystemPromptStore) UpsertChatSystemPrompt(ctx context.Context, prompt string) error { + if s.failNextUpsertChatSystemPrompt.CompareAndSwap(true, false) { + return stderrors.New("forced system prompt upsert failure") + } + return s.Store.UpsertChatSystemPrompt(ctx, prompt) +} + +// failNextAcquireLockStore lets a test force the next advisory lock +// acquisition for one lock ID to fail with a deadline error, simulating a +// lock wait that timed out. The failure state is shared across InTx +// wrappers. +type failNextAcquireLockStore struct { + database.Store + + lockID int64 + failNextAcquireLock *atomic.Bool +} + +func newFailNextAcquireLockStore(store database.Store, lockID int64) *failNextAcquireLockStore { + return &failNextAcquireLockStore{ + Store: store, + lockID: lockID, + failNextAcquireLock: &atomic.Bool{}, + } +} + +func (s *failNextAcquireLockStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return function(&failNextAcquireLockStore{ + Store: tx, + lockID: s.lockID, + failNextAcquireLock: s.failNextAcquireLock, + }) + }, txOpts) +} + +func (s *failNextAcquireLockStore) AcquireLock(ctx context.Context, id int64) error { + if id == s.lockID && s.failNextAcquireLock.CompareAndSwap(true, false) { + return context.DeadlineExceeded + } + return s.Store.AcquireLock(ctx, id) +} + +// failNextUpsertChatPlanModeInstructionsStore lets a test force the plan-mode +// instructions upsert to fail once, sharing its failure state across InTx +// wrappers. +type failNextUpsertChatPlanModeInstructionsStore struct { + database.Store + + failNextUpsertChatPlanModeInstructions *atomic.Bool +} + +func newFailNextUpsertChatPlanModeInstructionsStore(store database.Store) *failNextUpsertChatPlanModeInstructionsStore { + return &failNextUpsertChatPlanModeInstructionsStore{ + Store: store, + failNextUpsertChatPlanModeInstructions: &atomic.Bool{}, + } +} + +func (s *failNextUpsertChatPlanModeInstructionsStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return function(&failNextUpsertChatPlanModeInstructionsStore{ + Store: tx, + failNextUpsertChatPlanModeInstructions: s.failNextUpsertChatPlanModeInstructions, + }) + }, txOpts) +} + +func (s *failNextUpsertChatPlanModeInstructionsStore) UpsertChatPlanModeInstructions(ctx context.Context, instructions string) error { + if s.failNextUpsertChatPlanModeInstructions.CompareAndSwap(true, false) { + return stderrors.New("forced plan mode instructions upsert failure") + } + return s.Store.UpsertChatPlanModeInstructions(ctx, instructions) +} + +// lockSwitchChatPlanModeInstructionsStore can disable the per-setting +// advisory lock (to prove its effect) and stall each transaction after its +// Old-capture read until every concurrent transaction has read, so the +// stale-Old race the lock prevents is deterministic when the lock is off. +// The lock must stay enabled in any test that leaves it on, or the barrier +// deadlocks. +type lockSwitchChatPlanModeInstructionsStore struct { + database.Store + + lockEnabled *atomic.Bool + mu *sync.Mutex + parked *int + target *atomic.Int64 + release chan struct{} +} + +func newLockSwitchChatPlanModeInstructionsStore(store database.Store) *lockSwitchChatPlanModeInstructionsStore { + return &lockSwitchChatPlanModeInstructionsStore{ + Store: store, + lockEnabled: &atomic.Bool{}, + mu: &sync.Mutex{}, + parked: new(int), + target: &atomic.Int64{}, + release: make(chan struct{}), + } +} + +func (s *lockSwitchChatPlanModeInstructionsStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return function(&lockSwitchChatPlanModeInstructionsStore{ + Store: tx, + lockEnabled: s.lockEnabled, + mu: s.mu, + parked: s.parked, + target: s.target, + release: s.release, + }) + }, txOpts) +} + +func (s *lockSwitchChatPlanModeInstructionsStore) AcquireLock(ctx context.Context, id int64) error { + if !s.lockEnabled.Load() { + return nil + } + return s.Store.AcquireLock(ctx, id) +} + +func (s *lockSwitchChatPlanModeInstructionsStore) GetChatPlanModeInstructions(ctx context.Context) (string, error) { + // Read before parking so every waiter holds the same stale value no + // matter how goroutines are scheduled afterwards. Parking before the + // read lets the releasing goroutine write and commit before a slowly + // waking waiter executes its read, which then sees the committed new + // value under read committed and turns into a no-op. + instructions, err := s.Store.GetChatPlanModeInstructions(ctx) + if s.target.Load() > 0 { + s.mu.Lock() + *s.parked++ + done := *s.parked >= int(s.target.Load()) + s.mu.Unlock() + if done { + close(s.release) + } else { + <-s.release + } + } + return instructions, err +} + // failNextUpdateChatModelConfigStore shares its failure state across InTx // wrappers so tests can force a specific in-transaction model-config update to // return sql.ErrNoRows. @@ -12466,7 +12680,7 @@ If a workspace is needed, use list_templates before create_workspace and follow ctx := testutil.Context(t, testutil.WaitLong) rawDB, pubsub := dbtestutil.NewDB(t) - store := &failNextChatSystemPromptStore{Store: rawDB} + store := newFailNextChatSystemPromptStore(rawDB) rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ Database: store, Pubsub: pubsub, @@ -12636,7 +12850,7 @@ If a workspace is needed, use list_templates before create_workspace and follow ctx := testutil.Context(t, testutil.WaitLong) rawDB, pubsub := dbtestutil.NewDB(t) - store := &failNextChatSystemPromptStore{Store: rawDB} + store := newFailNextChatSystemPromptStore(rawDB) rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ Database: store, Pubsub: pubsub, @@ -12685,7 +12899,7 @@ If a workspace is needed, use list_templates before create_workspace and follow ctx := testutil.Context(t, testutil.WaitLong) rawDB, pubsub := dbtestutil.NewDB(t) - store := &failNextChatSystemPromptStore{Store: rawDB} + store := newFailNextChatSystemPromptStore(rawDB) rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ Database: store, Pubsub: pubsub, @@ -12767,6 +12981,455 @@ If a workspace is needed, use list_templates before create_workspace and follow sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "System prompt exceeds maximum length.", sdkErr.Message) }) + + t.Run("Audit", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + mAudit := audit.NewMock() + auditClient := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) + _ = coderdtest.CreateFirstUser(t, auditClient.Client) + // Discard the login entry emitted by user creation. + mAudit.ResetLogs() + + // A value change emits a Write entry with the setting's stable + // identity. + err := auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "You are a test assistant.", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + require.NoError(t, err) + + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, database.AuditActionWrite, logs[0].Action) + require.Equal(t, database.ResourceTypeChatInstructionSettings, logs[0].ResourceType) + require.Equal(t, audit.ChatInstructionSystemPromptID, logs[0].ResourceID) + require.Equal(t, "System prompt", logs[0].ResourceTarget) + require.Equal(t, uuid.Nil, logs[0].OrganizationID) + require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) + + // A value-identical PUT emits nothing (the write still happens). + mAudit.ResetLogs() + err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "You are a test assistant.", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + require.NoError(t, err) + require.Empty(t, mAudit.AuditLogs()) + + resp, err := auditClient.GetChatSystemPrompt(ctx) + require.NoError(t, err) + require.Equal(t, "You are a test assistant.", resp.SystemPrompt) + require.True(t, resp.IncludeDefaultSystemPrompt) + + // A second distinct change keeps the same stable identity. + mAudit.ResetLogs() + err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Renamed assistant.", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + require.NoError(t, err) + logs = mAudit.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, audit.ChatInstructionSystemPromptID, logs[0].ResourceID) + require.Equal(t, "System prompt", logs[0].ResourceTarget) + + // An omitted-flag PUT is not mis-suppressed: the stored toggle row + // keeps the effective include-default flag false, so a prompt-only + // change still emits an entry. + mAudit.ResetLogs() + err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Custom without default.", + IncludeDefaultSystemPrompt: ptr.Ref(false), + }) + require.NoError(t, err) + require.Len(t, mAudit.AuditLogs(), 1) + + mAudit.ResetLogs() + err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Renamed without default.", + }) + require.NoError(t, err) + require.Len(t, mAudit.AuditLogs(), 1) + + resp, err = auditClient.GetChatSystemPrompt(ctx) + require.NoError(t, err) + require.Equal(t, "Renamed without default.", resp.SystemPrompt) + require.False(t, resp.IncludeDefaultSystemPrompt) + + // A failed PUT records the attempt with an empty diff. + mAudit.ResetLogs() + tooLong := strings.Repeat("a", 131073) + err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: tooLong, + }) + requireSDKError(t, err, http.StatusBadRequest) + logs = mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusBadRequest, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) + }) + + // A denied PUT records the attempt with status 403, an empty diff, and + // no request body content in the row. + t.Run("AuditDeniedPUTRecordsAttempt", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + mAudit := audit.NewMock() + auditClient := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) + firstUser := coderdtest.CreateFirstUser(t, auditClient.Client) + // Discard the login entry emitted by user creation. + mAudit.ResetLogs() + + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, auditClient.Client, firstUser.OrganizationID) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + mAudit.ResetLogs() + + err := memberClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "must not leak into the audit row", + }) + requireSDKError(t, err, http.StatusForbidden) + + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, database.ResourceTypeChatInstructionSettings, logs[0].ResourceType) + require.Equal(t, audit.ChatInstructionSystemPromptID, logs[0].ResourceID) + require.Equal(t, "System prompt", logs[0].ResourceTarget) + require.EqualValues(t, http.StatusForbidden, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) + require.NotContains(t, string(logs[0].Diff), "must not leak") + }) + + // A failing upsert rolls the transaction back: the request fails with + // 500, no audit entry is emitted, and the stale Old the failed request + // captured must not poison a later no-change PUT into being audited + // as a change. + t.Run("AuditUpsertFailureThenNoChangeSuppressed", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextUpsertChatSystemPromptStore(rawDB) + mAudit := audit.NewMock() + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + Auditor: mAudit, + }) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) + client := codersdk.NewExperimentalClient(rawClient) + _ = coderdtest.CreateFirstUser(t, client.Client) + // Discard the login entry emitted by user creation. + mAudit.ResetLogs() + + store.failNextUpsertChatSystemPrompt.Store(true) + err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "First prompt.", + IncludeDefaultSystemPrompt: ptr.Ref(false), + }) + requireSDKError(t, err, http.StatusInternalServerError) + // The failed request records the attempt with an empty diff. + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusInternalServerError, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) + + // The failed write rolled back, so nothing was stored: the stale + // Old from the failed request must not survive into this one, or + // the entry below would diff against "First prompt.". Writing the + // deployment default materializes the override row (absent -> + // explicit true), which the presence field now audits. + mAudit.ResetLogs() + err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + require.NoError(t, err) + logs = mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) + + // With the override row now stored, the identical PUT is a true + // no-op: nothing is audited. + mAudit.ResetLogs() + err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + require.NoError(t, err) + require.Empty(t, mAudit.AuditLogs()) + }) + + // The advisory lock only exists for audit change detection, so a lock + // wait that times out must not fail a write that succeeded before the + // audit wiring existed: the handler falls back to the unaudited write + // path and emits no entry. + t.Run("AuditLockFailureFallsBackWithoutEntry", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextAcquireLockStore(rawDB, database.LockIDChatInstructionSystemPrompt) + mAudit := audit.NewMock() + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + Auditor: mAudit, + }) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) + client := codersdk.NewExperimentalClient(rawClient) + _ = coderdtest.CreateFirstUser(t, client.Client) + // Discard the login entry emitted by user creation. + mAudit.ResetLogs() + + store.failNextAcquireLock.Store(true) + err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Prompt written despite lock timeout.", + }) + require.NoError(t, err) + require.Empty(t, mAudit.AuditLogs()) + + resp, err := client.GetChatSystemPrompt(ctx) + require.NoError(t, err) + require.Equal(t, "Prompt written despite lock timeout.", resp.SystemPrompt) + + // The next PUT reacquires the lock normally and audits the change + // against the fallback-written value. + mAudit.ResetLogs() + err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Prompt written after lock recovery.", + }) + require.NoError(t, err) + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) + }) + + // The derived New must match the getter's computed effective state on + // the happy path for every combination of written prompt and + // include-default input, so the derivation cannot drift from + // GetChatSystemPromptConfig's rule. + t.Run("AuditDerivedNewMatchesGetter", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + prompt string + explicit *bool + }{ + {"EmptyPromptOmittedToggle", "", nil}, + {"EmptyPromptExplicitTrue", "", ptr.Ref(true)}, + {"EmptyPromptExplicitFalse", "", ptr.Ref(false)}, + {"NonEmptyPromptOmittedToggle", "Custom instructions.", nil}, + {"NonEmptyPromptExplicitTrue", "Custom instructions.", ptr.Ref(true)}, + {"NonEmptyPromptExplicitFalse", "Custom instructions.", ptr.Ref(false)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + auditClient, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, auditClient.Client) + + err := auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: tc.prompt, + IncludeDefaultSystemPrompt: tc.explicit, + }) + require.NoError(t, err) + + // The getter's computed state after the write. + stored, err := db.GetChatSystemPromptConfig(dbauthz.AsSystemRestricted(ctx)) + require.NoError(t, err) + + // The derivation the handler applies, computed from the + // written values and the pre-write stored state (empty + // here, fresh deployment). + newIncludeSet := false + newIncludeValue := true + if tc.explicit != nil { + newIncludeSet = true + newIncludeValue = *tc.explicit + } + if !newIncludeSet { + newIncludeValue = tc.prompt == "" + } + + require.Equal(t, tc.prompt, stored.ChatSystemPrompt) + require.Equal(t, newIncludeSet, stored.IncludeDefaultSystemPromptSet, + "derived presence must match the getter") + require.Equal(t, newIncludeValue, stored.IncludeDefaultSystemPrompt, + "derived effective value must match the getter") + }) + } + }) + + // Two concurrent identical PUTs both succeed, but the advisory lock + // makes the second comparison see the first's committed state, so + // only the first write is audited as a change. + // Two concurrent identical PUTs of a new value both succeed, but the + // per-setting lock serializes the Old capture with the write, so the + // first is a real change and the second sees the first's committed + // state and is a no-op: one entry. + t.Run("AuditConcurrentIdenticalPUTsSingleEntry", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + mAudit := audit.NewMock() + auditClient := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) + _ = coderdtest.CreateFirstUser(t, auditClient.Client) + // Discard the login entry emitted by user creation. + mAudit.ResetLogs() + + err := auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Seed prompt.", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + require.NoError(t, err) + require.Len(t, mAudit.AuditLogs(), 1) + + mAudit.ResetLogs() + const puts = 2 + start := make(chan struct{}) + errs := make(chan error, puts) + var wg sync.WaitGroup + for range puts { + wg.Add(1) + go func() { + defer wg.Done() + <-start + errs <- auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Concurrent prompt.", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + }() + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, database.AuditActionWrite, logs[0].Action) + }) + + // The audit diff for the fallback flip must carry the re-read boolean, + // not the stale Old value: a regression that built New from the Old + // capture would emit no include_default_system_prompt diff entry. The + // mock's test-supplied diff function pins the captured Old/New pair, + // which the default empty mock diff cannot express. + t.Run("AuditFallbackFlipDiffCarriesReReadValue", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + // This runs on the HTTP handler goroutine (via the deferred + // commitAudit), so it must use assert, not require: require calls + // t.FailNow, which is only legal on the test goroutine. + mAudit := audit.NewMockWithDiffFn(func(old, newVal any) audit.Map { + oldSettings, ok := old.(database.ChatInstructionSettings) + assert.True(t, ok) + newSettings, ok := newVal.(database.ChatInstructionSettings) + assert.True(t, ok) + return audit.Map{ + "system_prompt": {Old: oldSettings.SystemPrompt, New: newSettings.SystemPrompt}, + "include_default_system_prompt": {Old: oldSettings.IncludeDefaultSystemPrompt, New: newSettings.IncludeDefaultSystemPrompt}, + } + }) + auditClient := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) + _ = coderdtest.CreateFirstUser(t, auditClient.Client) + // Discard the login entry emitted by user creation. + mAudit.ResetLogs() + + // Seed only the prompt key, like a pre-toggle deployment: with no + // include-default row, the effective flag falls back to false + // while a non-empty custom prompt exists. + err := auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Legacy custom instructions.", + }) + require.NoError(t, err) + require.Len(t, mAudit.AuditLogs(), 1) + mAudit.ResetLogs() + + // Clearing the prompt without sending the flag flips the + // effective include-default value from false to true. The diff + // must show that flip, which is only possible if New came from + // the post-write re-read. + err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "", + }) + require.NoError(t, err) + + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + var diff map[string]codersdk.AuditDiffField + require.NoError(t, json.Unmarshal(logs[0].Diff, &diff)) + require.Equal(t, map[string]codersdk.AuditDiffField{ + "system_prompt": {Old: "Legacy custom instructions.", New: ""}, + "include_default_system_prompt": {Old: false, New: true}, + }, diff) + }) + + t.Run("AuditIncludeDefaultFallbackFlip", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + mAudit := audit.NewMock() + auditClient := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) + _ = coderdtest.CreateFirstUser(t, auditClient.Client) + // Discard the login entry emitted by user creation. + mAudit.ResetLogs() + + // Seed only the prompt key, like a pre-toggle deployment: with no + // include-default row, the effective flag falls back to false + // while a non-empty custom prompt exists. + err := auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Legacy custom instructions.", + }) + require.NoError(t, err) + require.Len(t, mAudit.AuditLogs(), 1) + + resp, err := auditClient.GetChatSystemPrompt(ctx) + require.NoError(t, err) + require.Equal(t, "Legacy custom instructions.", resp.SystemPrompt) + require.False(t, resp.IncludeDefaultSystemPrompt) + + // Clearing the prompt without sending the flag flips the + // effective include-default value from false to true. The entry + // must be emitted and its diff must carry the boolean change, + // which is why the handler re-reads the pair after writing. + mAudit.ResetLogs() + err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "", + }) + require.NoError(t, err) + + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, database.AuditActionWrite, logs[0].Action) + require.Equal(t, database.ResourceTypeChatInstructionSettings, logs[0].ResourceType) + + resp, err = auditClient.GetChatSystemPrompt(ctx) + require.NoError(t, err) + require.Empty(t, resp.SystemPrompt) + require.True(t, resp.IncludeDefaultSystemPrompt) + + // Repeating the cleared state changes nothing: no entry. + mAudit.ResetLogs() + err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "", + }) + require.NoError(t, err) + require.Empty(t, mAudit.AuditLogs()) + }) } //nolint:tparallel,paralleltest // Subtests share a single coderdtest instance. @@ -12841,6 +13504,313 @@ func TestChatPlanModeInstructions(t *testing.T) { _, err := memberClient.GetChatPlanModeInstructions(ctx) requireSDKError(t, err, http.StatusNotFound) }) + + t.Run("Audit", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + mAudit := audit.NewMock() + auditClient := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) + _ = coderdtest.CreateFirstUser(t, auditClient.Client) + // Discard the login entry emitted by user creation. + mAudit.ResetLogs() + + // A value change emits a Write entry with the setting's stable + // identity, distinct from the system prompt's. + err := auditClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Draft a plan first.", + }) + require.NoError(t, err) + + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, database.AuditActionWrite, logs[0].Action) + require.Equal(t, database.ResourceTypeChatInstructionSettings, logs[0].ResourceType) + require.Equal(t, audit.ChatInstructionPlanModeID, logs[0].ResourceID) + require.Equal(t, "Plan mode instructions", logs[0].ResourceTarget) + require.NotEqual(t, audit.ChatInstructionSystemPromptID, logs[0].ResourceID) + require.Equal(t, uuid.Nil, logs[0].OrganizationID) + require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) + + // A value-identical PUT emits nothing (the write still happens). + mAudit.ResetLogs() + err = auditClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Draft a plan first.", + }) + require.NoError(t, err) + require.Empty(t, mAudit.AuditLogs()) + + resp, err := auditClient.GetChatPlanModeInstructions(ctx) + require.NoError(t, err) + require.Equal(t, "Draft a plan first.", resp.PlanModeInstructions) + + // A second distinct change keeps the same stable identity. + mAudit.ResetLogs() + err = auditClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Draft a better plan.", + }) + require.NoError(t, err) + logs = mAudit.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, audit.ChatInstructionPlanModeID, logs[0].ResourceID) + require.Equal(t, "Plan mode instructions", logs[0].ResourceTarget) + + // A failed PUT records the attempt with an empty diff. + mAudit.ResetLogs() + tooLong := strings.Repeat("a", 131073) + err = auditClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: tooLong, + }) + requireSDKError(t, err, http.StatusBadRequest) + logs = mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusBadRequest, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) + }) + + // A denied PUT records the attempt with status 403, an empty diff, and + // no request body content in the row. + t.Run("AuditDeniedPUTRecordsAttempt", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + mAudit := audit.NewMock() + auditClient := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) + firstUser := coderdtest.CreateFirstUser(t, auditClient.Client) + // Discard the login entry emitted by user creation. + mAudit.ResetLogs() + + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, auditClient.Client, firstUser.OrganizationID) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + mAudit.ResetLogs() + + err := memberClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "must not leak into the audit row", + }) + requireSDKError(t, err, http.StatusForbidden) + + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, database.ResourceTypeChatInstructionSettings, logs[0].ResourceType) + require.Equal(t, audit.ChatInstructionPlanModeID, logs[0].ResourceID) + require.Equal(t, "Plan mode instructions", logs[0].ResourceTarget) + require.EqualValues(t, http.StatusForbidden, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) + require.NotContains(t, string(logs[0].Diff), "must not leak") + }) + + // A failing upsert rolls the transaction back: the request fails with + // 500, no audit entry is emitted, and the stale Old the failed request + // captured must not poison a later no-change PUT into being audited + // as a change. + t.Run("AuditUpsertFailureThenNoChangeSuppressed", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextUpsertChatPlanModeInstructionsStore(rawDB) + mAudit := audit.NewMock() + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + Auditor: mAudit, + }) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) + client := codersdk.NewExperimentalClient(rawClient) + _ = coderdtest.CreateFirstUser(t, client.Client) + // Discard the login entry emitted by user creation. + mAudit.ResetLogs() + + err := client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Persist me.", + }) + require.NoError(t, err) + require.Len(t, mAudit.AuditLogs(), 1) + + mAudit.ResetLogs() + store.failNextUpsertChatPlanModeInstructions.Store(true) + err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Never stored.", + }) + sdkErr := requireSDKError(t, err, http.StatusInternalServerError) + // The response detail must be the raw write error, byte-identical + // to the pre-transaction endpoint, not the InTx wrapper. + require.Equal(t, "forced plan mode instructions upsert failure", sdkErr.Detail) + // The failed request records the attempt with an empty diff. + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusInternalServerError, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) + + // The failed write rolled back, so the stored value is still + // "Persist me." and repeating it is a no-op: no entry. The stale + // Old from the failed request must not survive into this one. + mAudit.ResetLogs() + err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Persist me.", + }) + require.NoError(t, err) + require.Empty(t, mAudit.AuditLogs()) + }) + + // A lock wait that times out falls back to the unaudited write path: + // the write succeeds, and because no baseline was captured, no entry + // is emitted (not even an empty-diff success entry). + t.Run("AuditLockFailureFallsBackWithoutEntry", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextAcquireLockStore(rawDB, database.LockIDChatInstructionPlanMode) + mAudit := audit.NewMock() + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + Auditor: mAudit, + }) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) + client := codersdk.NewExperimentalClient(rawClient) + _ = coderdtest.CreateFirstUser(t, client.Client) + // Discard the login entry emitted by user creation. + mAudit.ResetLogs() + + store.failNextAcquireLock.Store(true) + err := client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Written despite lock timeout.", + }) + require.NoError(t, err) + require.Empty(t, mAudit.AuditLogs()) + + resp, err := client.GetChatPlanModeInstructions(ctx) + require.NoError(t, err) + require.Equal(t, "Written despite lock timeout.", resp.PlanModeInstructions) + + // The next PUT reacquires the lock normally and audits the change + // against the fallback-written value. + mAudit.ResetLogs() + err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Written after lock recovery.", + }) + require.NoError(t, err) + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) + }) + + // Two concurrent identical PUTs both succeed, but the per-setting lock + // serializes the Old capture with the write, so the second + // transaction's comparison sees the first's committed state and only + // the first write is audited as a change. The barrier forces both + // transactions to the Old capture before either commits, so the + // lock-off case below reproduces the stale-Old race deterministically. + t.Run("AuditConcurrentIdenticalPUTsSingleEntry", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newLockSwitchChatPlanModeInstructionsStore(rawDB) + store.lockEnabled.Store(true) + mAudit := audit.NewMock() + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + Auditor: mAudit, + }) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) + client := codersdk.NewExperimentalClient(rawClient) + _ = coderdtest.CreateFirstUser(t, client.Client) + // Discard the login entry emitted by user creation. + mAudit.ResetLogs() + + err := client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Seed instructions.", + }) + require.NoError(t, err) + require.Len(t, mAudit.AuditLogs(), 1) + + // Concurrent identical PUTs of a NEW value under the lock: the + // first is a real change, the second sees the first's committed + // value and is a no-op. + mAudit.ResetLogs() + const puts = 2 + start := make(chan struct{}) + errs := make(chan error, puts) + var wg sync.WaitGroup + for range puts { + wg.Add(1) + go func() { + defer wg.Done() + <-start + errs <- client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Concurrent instructions.", + }) + }() + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + require.Len(t, mAudit.AuditLogs(), 1) + }) + + // With the lock removed, the barrier parks both transactions at the + // Old capture before either writes, so both read the same stale Old, + // both write, and both emit: the exact duplicate the lock prevents. + // This is the mutation proof for the lock. + t.Run("AuditConcurrentIdenticalPUTsDuplicateWithoutLock", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newLockSwitchChatPlanModeInstructionsStore(rawDB) + // Lock disabled. + mAudit := audit.NewMock() + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + Auditor: mAudit, + }) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) + client := codersdk.NewExperimentalClient(rawClient) + _ = coderdtest.CreateFirstUser(t, client.Client) + // Discard the login entry emitted by user creation. + mAudit.ResetLogs() + + err := client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Seed instructions.", + }) + require.NoError(t, err) + require.Len(t, mAudit.AuditLogs(), 1) + + mAudit.ResetLogs() + store.target.Store(2) + const puts = 2 + start := make(chan struct{}) + errs := make(chan error, puts) + var wg sync.WaitGroup + for range puts { + wg.Add(1) + go func() { + defer wg.Done() + <-start + errs <- client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Concurrent instructions.", + }) + }() + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + // Both captured the same stale Old and both wrote: two entries. + require.Len(t, mAudit.AuditLogs(), 2) + }) } //nolint:tparallel,paralleltest // Setting subtests share per-setting coderdtest instances. diff --git a/codersdk/audit.go b/codersdk/audit.go index 637410807a234..7ee41ce51581d 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -44,17 +44,18 @@ const ( 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" + 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" + ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings" ) func (r ResourceType) FriendlyString() string { @@ -133,6 +134,8 @@ func (r ResourceType) FriendlyString() string { return "user secret" case ResourceTypeUserSkill: return "user skill" + case ResourceTypeChatInstructionSettings: + return "chat instruction settings" default: return "unknown" } diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 136dcb50fc803..27082192dced3 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -26,6 +26,7 @@ We track the following resources: | AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| | AuditableUserAIBudgetOverride
write, delete | |
FieldTracked
created_atfalse
group_idtrue
group_nametrue
spend_limittrue
spend_limit_microsfalse
updated_atfalse
user_idfalse
usernamefalse
| | Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
compaction_requested_atfalse
context_aggregate_hashfalse
context_dirty_resourcesfalse
context_dirty_sincefalse
context_errorfalse
created_atfalse
dynamic_toolsfalse
generation_attemptfalse
group_acltrue
heartbeat_atfalse
history_versionfalse
idtrue
labelstrue
last_errorfalse
last_model_config_idfalse
last_read_message_idfalse
last_reasoning_effortfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
queue_versionfalse
requires_action_deadline_atfalse
retry_statefalse
retry_state_versionfalse
root_chat_idfalse
runner_idfalse
snapshot_versionfalse
started_atfalse
statusfalse
summaryfalse
summary_generated_atfalse
titletrue
updated_atfalse
user_acltrue
worker_idfalse
workspace_idtrue
| +| ChatInstructionSettings
write | |
FieldTracked
idfalse
include_default_system_prompttrue
include_default_system_prompt_settrue
namefalse
plan_mode_instructionstrue
system_prompttrue
| | CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| | GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
private_key_key_idfalse
public_keytrue
updated_atfalse
user_idtrue
| | GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 6383f702336b6..2c20028c82ff0 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -11542,9 +11542,9 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith #### Enumerated Values -| Value(s) | -|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_gateway_key`, `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `oauth2_provider_settings`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_ai_budget_override`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_gateway_key`, `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `chat_instruction_settings`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `oauth2_provider_settings`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_ai_budget_override`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | ## codersdk.Response diff --git a/enterprise/audit/diff_internal_test.go b/enterprise/audit/diff_internal_test.go index bca368d8603d5..3a4eea8480f1c 100644 --- a/enterprise/audit/diff_internal_test.go +++ b/enterprise/audit/diff_internal_test.go @@ -504,6 +504,61 @@ func Test_diff(t *testing.T) { }, }, }) + + runDiffTests(t, []diffTest{ + { + // Prompt text is tracked, not secret: reviewers must see what + // agents are told to do (CODAGT-719). The system-prompt + // endpoint populates only its two fields; the untouched + // plan-mode field stays zero on both sides and never diffs. + name: "SystemPromptChangeTracked", + left: database.ChatInstructionSettings{ + SystemPrompt: "old instructions", + IncludeDefaultSystemPrompt: true, + }, + right: database.ChatInstructionSettings{ + ID: uuid.UUID{1}, + SystemPrompt: "new instructions", + IncludeDefaultSystemPrompt: false, + }, + exp: audit.Map{ + "system_prompt": audit.OldNew{Old: "old instructions", New: "new instructions"}, + "include_default_system_prompt": audit.OldNew{Old: true, New: false}, + }, + }, + { + // The plan-mode endpoint populates only its own field; the + // system-prompt fields stay zero on both sides, so a + // plan-mode change diffs exactly one field. + name: "PlanModeInstructionsChangeTracked", + left: database.ChatInstructionSettings{ + PlanModeInstructions: "old plan guidance", + }, + right: database.ChatInstructionSettings{ + ID: uuid.UUID{1}, + PlanModeInstructions: "new plan guidance", + }, + exp: audit.Map{ + "plan_mode_instructions": audit.OldNew{Old: "old plan guidance", New: "new plan guidance"}, + }, + }, + { + // The artificial ID is ignored, so a value-identical write + // would diff empty. Handlers additionally suppress the entry + // entirely by leaving both resource IDs nil. + name: "ArtificialIDIgnored", + left: database.ChatInstructionSettings{ + SystemPrompt: "same", + IncludeDefaultSystemPrompt: true, + }, + right: database.ChatInstructionSettings{ + ID: uuid.UUID{1}, + SystemPrompt: "same", + IncludeDefaultSystemPrompt: true, + }, + exp: audit.Map{}, + }, + }) } func runDiffTests(t *testing.T, tests []diffTest) { diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index a58d523d7db76..4e1082a67b138 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -37,6 +37,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{ "Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted. "UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "ChatInstructionSettings": {codersdk.AuditActionWrite}, } type Action string @@ -284,6 +285,14 @@ var auditableResourcesTypes = map[any]map[string]Action{ "id": ActionIgnore, "dynamic_client_registration_enabled": ActionTrack, }, + &database.ChatInstructionSettings{}: { + "id": ActionIgnore, + "name": ActionIgnore, + "system_prompt": ActionTrack, + "include_default_system_prompt_set": ActionTrack, + "include_default_system_prompt": ActionTrack, + "plan_mode_instructions": ActionTrack, + }, // TODO: track an ID here when the below ticket is completed: // https://github.com/coder/coder/pull/6012 &database.License{}: { diff --git a/enterprise/coderd/chatinstructions_audit_test.go b/enterprise/coderd/chatinstructions_audit_test.go new file mode 100644 index 0000000000000..e6d810f0e05a3 --- /dev/null +++ b/enterprise/coderd/chatinstructions_audit_test.go @@ -0,0 +1,193 @@ +package coderd_test + +import ( + "context" + "encoding/json" + stderrors "errors" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + entaudit "github.com/coder/coder/v2/enterprise/audit" + "github.com/coder/coder/v2/testutil" +) + +// captureBackend is an enterprise audit backend that records exported logs +// so tests can assert on the production differ's output. +type captureBackend struct { + mu sync.Mutex + logs []database.AuditLog +} + +func (*captureBackend) Decision() entaudit.FilterDecision { + return entaudit.FilterDecisionStore | entaudit.FilterDecisionExport +} + +func (b *captureBackend) Export(_ context.Context, alog database.AuditLog, _ entaudit.BackendDetails) error { + b.mu.Lock() + defer b.mu.Unlock() + b.logs = append(b.logs, alog) + return nil +} + +func (b *captureBackend) reset() { + b.mu.Lock() + defer b.mu.Unlock() + b.logs = nil +} + +func (b *captureBackend) entries() []database.AuditLog { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]database.AuditLog, len(b.logs)) + copy(out, b.logs) + return out +} + +// failNextGetChatSystemPromptConfigStore fails the Old capture read of the +// chat system prompt configuration once. Failure state is shared across InTx +// wrappers. +type failNextGetChatSystemPromptConfigStore struct { + database.Store + + fail *atomic.Bool +} + +func newFailNextGetChatSystemPromptConfigStore(store database.Store) *failNextGetChatSystemPromptConfigStore { + return &failNextGetChatSystemPromptConfigStore{ + Store: store, + fail: &atomic.Bool{}, + } +} + +func (s *failNextGetChatSystemPromptConfigStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return function(&failNextGetChatSystemPromptConfigStore{ + Store: tx, + fail: s.fail, + }) + }, txOpts) +} + +func (s *failNextGetChatSystemPromptConfigStore) GetChatSystemPromptConfig(ctx context.Context) (database.GetChatSystemPromptConfigRow, error) { + if s.fail.CompareAndSwap(true, false) { + return database.GetChatSystemPromptConfigRow{}, stderrors.New("forced old capture failure") + } + return s.Store.GetChatSystemPromptConfig(ctx) +} + +// TestChatInstructionSettingsOldCaptureBestEffort proves the Old capture is +// best effort: a failed Old read costs nothing (nothing was written yet), so +// the handler runs the direct write path and emits no audit entry. There is +// no post-write read to fail, so no diff can ever be fabricated from a zero +// value. +func TestChatInstructionSettingsOldCaptureBestEffort(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextGetChatSystemPromptConfigStore(rawDB) + backend := &captureBackend{} + auditor := entaudit.NewAuditor(rawDB, entaudit.DefaultFilter, backend) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + rawClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + Auditor: auditor, + Logger: &logger, + }) + client := codersdk.NewExperimentalClient(rawClient) + _ = coderdtest.CreateFirstUser(t, client.Client) + // Discard the login entry emitted by user creation. + backend.reset() + + // The Old capture fails before anything is written: the transaction + // is abandoned at no cost, the handler runs the direct write path, + // and no audit entry is emitted (there is no baseline to diff + // against, so any row would be a guess). + store.fail.Store(true) + err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Changed prompt.", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + require.NoError(t, err) + require.Empty(t, backend.entries()) + + resp, err := client.GetChatSystemPrompt(ctx) + require.NoError(t, err) + require.Equal(t, "Changed prompt.", resp.SystemPrompt) + require.True(t, resp.IncludeDefaultSystemPrompt) +} + +// TestChatInstructionSettingsIncludeDefaultPresence exercises the production +// differ against the include-default presence transition: writing an explicit +// false over a legacy absent override row does not move the effective value +// but inserts the persistent row, which changes future behavior, so the +// presence transition must be audited. +func TestChatInstructionSettingsIncludeDefaultPresence(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + backend := &captureBackend{} + auditor := entaudit.NewAuditor(rawDB, entaudit.DefaultFilter, backend) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + rawClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: rawDB, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + Auditor: auditor, + Logger: &logger, + }) + client := codersdk.NewExperimentalClient(rawClient) + _ = coderdtest.CreateFirstUser(t, client.Client) + // Discard the login entry emitted by user creation. + backend.reset() + + // Legacy shape: a non-empty prompt with no override row computes + // effective false. + err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Legacy custom instructions.", + }) + require.NoError(t, err) + require.Len(t, backend.entries(), 1) + + // Explicit false over the absent row: same effective value, but the + // override row now exists, so the entry must record the presence + // transition. + backend.reset() + err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Legacy custom instructions.", + IncludeDefaultSystemPrompt: ptr.Ref(false), + }) + require.NoError(t, err) + require.Len(t, backend.entries(), 1) + var diff map[string]codersdk.AuditDiffField + require.NoError(t, json.Unmarshal(backend.entries()[0].Diff, &diff)) + require.Equal(t, codersdk.AuditDiffField{Old: false, New: true}, + diff["include_default_system_prompt_set"]) + // The effective value did not move, and the prompt is unchanged. + require.NotContains(t, diff, "include_default_system_prompt") + require.NotContains(t, diff, "system_prompt") + + // Repeating the same explicit-false PUT is a true no-op now: the row + // exists with the same value, so nothing is audited. + backend.reset() + err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Legacy custom instructions.", + IncludeDefaultSystemPrompt: ptr.Ref(false), + }) + require.NoError(t, err) + require.Empty(t, backend.entries()) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 54a1e5dfd6129..74d8e009c6d8d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -7939,6 +7939,7 @@ export type ResourceType = | "ai_seat" | "api_key" | "chat" + | "chat_instruction_settings" | "convert_login" | "custom_role" | "git_ssh_key" @@ -7977,6 +7978,7 @@ export const ResourceTypes: ResourceType[] = [ "ai_seat", "api_key", "chat", + "chat_instruction_settings", "convert_login", "custom_role", "git_ssh_key", diff --git a/site/src/pages/AuditPage/AuditFilter.tsx b/site/src/pages/AuditPage/AuditFilter.tsx index 47fc09e3155c0..fa527dccc4cf0 100644 --- a/site/src/pages/AuditPage/AuditFilter.tsx +++ b/site/src/pages/AuditPage/AuditFilter.tsx @@ -155,6 +155,10 @@ export const useResourceTypeFilterMenu = ({ label = "Workspace Build"; } + if (type === "chat_instruction_settings") { + label = "Chat Instruction Settings"; + } + return { value: type, label, diff --git a/site/src/pages/AuditPage/AuditPageView.stories.tsx b/site/src/pages/AuditPage/AuditPageView.stories.tsx index 035dbe5fca518..d9d94536cb00f 100644 --- a/site/src/pages/AuditPage/AuditPageView.stories.tsx +++ b/site/src/pages/AuditPage/AuditPageView.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ComponentProps } from "react"; -import { expect, within } from "storybook/test"; +import { expect, fn, screen, userEvent, within } from "storybook/test"; import { getDefaultFilterProps, MockMenu, @@ -18,6 +18,7 @@ import { MockUserOwner, } from "#/testHelpers/entities"; import { pixelWithTablet } from "#/testHelpers/pixel"; +import { useResourceTypeFilterMenu } from "./AuditFilter"; import { AuditPageView } from "./AuditPageView"; type FilterProps = ComponentProps["filterProps"]; @@ -119,6 +120,53 @@ export const NotVisibleWithoutLicenseAccess: Story = { }, }; +const onResourceTypeChange = fn(); + +// Uses the real resource-type menu so the generated resource type and its +// friendly label are verified together. +export const FilterByChatInstructionSettings: Story = { + args: { + auditsQuery: mockSuccessResult, + }, + render: function AuditPageViewWithResourceTypeMenu(args) { + const resourceTypeMenu = useResourceTypeFilterMenu({ + value: undefined, + onChange: onResourceTypeChange, + }); + return ( + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + onResourceTypeChange.mockClear(); + + await userEvent.click( + canvas.getByRole("button", { name: "Select a resource type" }), + ); + const option = await screen.findByRole("option", { + name: "Chat Instruction Settings", + }); + await userEvent.click(option); + + await expect(onResourceTypeChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: "chat_instruction_settings", + label: "Chat Instruction Settings", + }), + ); + }, +}; + export const MultiOrg: Story = { parameters: { pixel: { matrix: pixelWithTablet } }, args: {