From e7d91542c41ca55a54def4fd4cd8905757e0a41f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 29 Jul 2026 22:26:00 +0000 Subject: [PATCH 01/16] feat: audit chat system instructions changes Register a chat_system_prompt_settings audit resource covering the deployment-wide chat system prompt, include-default toggle, and plan-mode instructions, and wire both PUT endpoints to it. The system-prompt handler reads the config pair before and after its existing conditional upserts inside the same transaction; the re-read is load-bearing because the effective include-default flag is computed from the toggle row and the current prompt, so a prompt-only write can flip it. The plan-mode handler wraps its previously transactionless upsert in a transaction to capture the old value. Write paths stay byte-identical: value-identical PUTs still upsert, and only the audit entry is suppressed by leaving both sides without a resource ID. --- coderd/apidoc/docs.go | 6 +- coderd/apidoc/swagger.json | 6 +- coderd/audit/diff.go | 3 +- coderd/audit/request.go | 12 + coderd/database/dump.sql | 3 +- ...audit_chat_system_prompt_settings.down.sql | 1 + ...1_audit_chat_system_prompt_settings.up.sql | 2 + coderd/database/models.go | 5 +- coderd/database/types.go | 13 + coderd/exp_chats.go | 80 ++++- coderd/exp_chats_test.go | 322 +++++++++++++++++- codersdk/audit.go | 25 +- docs/admin/security/audit-logs.md | 1 + docs/reference/api/schemas.md | 6 +- enterprise/audit/diff_internal_test.go | 55 +++ enterprise/audit/table.go | 7 + site/src/api/typesGenerated.ts | 2 + 17 files changed, 522 insertions(+), 27 deletions(-) create mode 100644 coderd/database/migrations/000561_audit_chat_system_prompt_settings.down.sql create mode 100644 coderd/database/migrations/000561_audit_chat_system_prompt_settings.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index bc7ecbda7ce..82be26e32ce 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_system_prompt_settings" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -24343,7 +24344,8 @@ const docTemplate = `{ "ResourceTypeUserAIBudgetOverride", "ResourceTypeChat", "ResourceTypeUserSecret", - "ResourceTypeUserSkill" + "ResourceTypeUserSkill", + "ResourceTypeChatSystemPromptSettings" ] }, "codersdk.Response": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 638c27ec248..be9872700bf 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_system_prompt_settings" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -22325,7 +22326,8 @@ "ResourceTypeUserAIBudgetOverride", "ResourceTypeChat", "ResourceTypeUserSecret", - "ResourceTypeUserSkill" + "ResourceTypeUserSkill", + "ResourceTypeChatSystemPromptSettings" ] }, "codersdk.Response": { diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 374f74dd7ab..3ba5373be01 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.ChatSystemPromptSettings } // 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 4b213968bd3..23a88717745 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -158,6 +158,9 @@ func ResourceTarget[T Auditable](tgt T) string { return typed.Name case database.UserSkill: return typed.Name + case database.ChatSystemPromptSettings: + // Deployment singleton, no target. + return "" default: panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt)) } @@ -243,6 +246,9 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { return typed.ID case database.UserSkill: return typed.ID + case database.ChatSystemPromptSettings: + // Artificial ID for auditing purposes. + return typed.ID default: panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt)) } @@ -318,6 +324,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeUserSecret case database.UserSkill: return database.ResourceTypeUserSkill + case database.ChatSystemPromptSettings: + return database.ResourceTypeChatSystemPromptSettings default: panic(fmt.Sprintf("unknown resource %T for ResourceType", typed)) } @@ -408,6 +416,10 @@ func ResourceRequiresOrgID[T Auditable]() bool { case database.UserSkill: // User skills are global to the user across organizations. return false + case database.ChatSystemPromptSettings: + // Artificial ID for auditing purposes. This is a deployment + // singleton, 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 92f2f77b975..5517bd66f8f 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_system_prompt_settings' ); CREATE TYPE shareable_workspace_owners AS ENUM ( diff --git a/coderd/database/migrations/000561_audit_chat_system_prompt_settings.down.sql b/coderd/database/migrations/000561_audit_chat_system_prompt_settings.down.sql new file mode 100644 index 00000000000..35020b349fc --- /dev/null +++ b/coderd/database/migrations/000561_audit_chat_system_prompt_settings.down.sql @@ -0,0 +1 @@ +-- No-op, enum values can't be dropped. diff --git a/coderd/database/migrations/000561_audit_chat_system_prompt_settings.up.sql b/coderd/database/migrations/000561_audit_chat_system_prompt_settings.up.sql new file mode 100644 index 00000000000..2547d1b7f95 --- /dev/null +++ b/coderd/database/migrations/000561_audit_chat_system_prompt_settings.up.sql @@ -0,0 +1,2 @@ +ALTER TYPE resource_type + ADD VALUE IF NOT EXISTS 'chat_system_prompt_settings'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 22aaada3fb3..db2601220c0 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" + ResourceTypeChatSystemPromptSettings ResourceType = "chat_system_prompt_settings" ) func (e *ResourceType) Scan(src interface{}) error { @@ -3609,7 +3610,8 @@ func (e ResourceType) Valid() bool { ResourceTypeUserSkill, ResourceTypeAIGatewayKey, ResourceTypeUserAIBudgetOverride, - ResourceTypeOauth2ProviderSettings: + ResourceTypeOauth2ProviderSettings, + ResourceTypeChatSystemPromptSettings: return true } return false @@ -3653,6 +3655,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeAIGatewayKey, ResourceTypeUserAIBudgetOverride, ResourceTypeOauth2ProviderSettings, + ResourceTypeChatSystemPromptSettings, } } diff --git a/coderd/database/types.go b/coderd/database/types.go index 68ad4b5a40f..001f17ff8cf 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -49,6 +49,19 @@ type OAuth2ProviderSettings struct { DynamicClientRegistrationEnabled bool `db:"dynamic_client_registration_enabled" json:"dynamic_client_registration_enabled"` } +// ChatSystemPromptSettings is the auditable shape of the deployment-wide +// chat system prompt 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 ChatSystemPromptSettings struct { + ID uuid.UUID `db:"id" json:"id"` + SystemPrompt string `db:"system_prompt" json:"system_prompt"` + 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 f98a22d11f3..a4b5cdd76d4 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4552,6 +4552,15 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { httpapi.Forbidden(rw) return } + + aReq, commitAudit := audit.InitRequest[database.ChatSystemPromptSettings](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + defer commitAudit() + // 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)) @@ -4570,6 +4579,14 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { return } err := api.Database.InTx(func(tx database.Store) error { + oldConfig, err := tx.GetChatSystemPromptConfig(ctx) + if err != nil { + return err + } + aReq.Old = database.ChatSystemPromptSettings{ + SystemPrompt: oldConfig.ChatSystemPrompt, + IncludeDefaultSystemPrompt: oldConfig.IncludeDefaultSystemPrompt, + } if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil { return err } @@ -4579,7 +4596,31 @@ 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 + } + } + // Re-read the pair to build New: the effective include-default flag + // is computed from the toggle row AND the current prompt, so a + // prompt-only write can flip it without the request carrying the + // flag. + newConfig, err := tx.GetChatSystemPromptConfig(ctx) + if err != nil { + return err + } + if newConfig.ChatSystemPrompt == oldConfig.ChatSystemPrompt && + newConfig.IncludeDefaultSystemPrompt == oldConfig.IncludeDefaultSystemPrompt { + // Value-identical PUT: leave both audit sides without a + // resource ID, which suppresses the audit entry entirely. + // The upserts above still run either way. + return nil + } + // Artificial ID for auditing purposes, set only on New so the + // entry shows the old-to-new transition. + aReq.New = database.ChatSystemPromptSettings{ + ID: uuid.New(), + SystemPrompt: newConfig.ChatSystemPrompt, + IncludeDefaultSystemPrompt: newConfig.IncludeDefaultSystemPrompt, } return nil }, nil) @@ -4625,6 +4666,14 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ return } + aReq, commitAudit := audit.InitRequest[database.ChatSystemPromptSettings](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + defer commitAudit() + // 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)) @@ -4643,7 +4692,34 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ return } - if err := api.Database.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil { + // The read and write share one transaction so the audited old value is + // exactly the value this request replaced. + err := api.Database.InTx(func(tx database.Store) error { + oldInstructions, err := tx.GetChatPlanModeInstructions(ctx) + if err != nil { + return err + } + aReq.Old = database.ChatSystemPromptSettings{ + PlanModeInstructions: oldInstructions, + } + if err := tx.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil { + return err + } + if sanitizedInstructions == oldInstructions { + // Value-identical PUT: leave both audit sides without a + // resource ID, which suppresses the audit entry entirely. + // The upsert above still runs either way. + return nil + } + // Artificial ID for auditing purposes, set only on New so the + // entry shows the old-to-new transition. + aReq.New = database.ChatSystemPromptSettings{ + ID: uuid.New(), + PlanModeInstructions: sanitizedInstructions, + } + return nil + }, nil) + if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error updating plan mode instructions.", Detail: err.Error(), diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index e57d4f089b0..7f0daf08eb1 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -200,12 +200,31 @@ 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 once. Flags that affect in-transaction writes are shared +// across InTx wrappers. type failNextChatSystemPromptStore struct { database.Store failNextGetChatIncludeDefaultSystemPrompt atomic.Bool failNextGetChatSystemPromptConfig atomic.Bool - failNextUpsertChatIncludeDefaultSystemPrompt atomic.Bool + failNextUpsertChatIncludeDefaultSystemPrompt *atomic.Bool +} + +func newFailNextChatSystemPromptStore(store database.Store) *failNextChatSystemPromptStore { + return &failNextChatSystemPromptStore{ + Store: store, + 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, + failNextUpsertChatIncludeDefaultSystemPrompt: s.failNextUpsertChatIncludeDefaultSystemPrompt, + }) + }, txOpts) } func (s *failNextChatSystemPromptStore) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) { @@ -229,6 +248,38 @@ func (s *failNextChatSystemPromptStore) GetChatSystemPromptConfig(ctx context.Co return s.Store.GetChatSystemPromptConfig(ctx) } +// 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) +} + // 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 +12517,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 +12687,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 +12736,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 +12818,176 @@ 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 for the deployment singleton. + 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.ResourceTypeChatSystemPromptSettings, logs[0].ResourceType) + require.Equal(t, "", logs[0].ResourceTarget) + require.NotEqual(t, uuid.Nil, 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.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) + + // 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 emits nothing. + mAudit.ResetLogs() + tooLong := strings.Repeat("a", 131073) + err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: tooLong, + }) + requireSDKError(t, err, http.StatusBadRequest) + require.Empty(t, mAudit.AuditLogs()) + }) + + // A failing write must not leave a stale Old behind: the request + // fails with 500 and no audit entry, and a later no-change PUT is + // still recognized as unchanged (suppressed) rather than audited as + // a change from the value the failed request had read. + t.Run("AuditFailureThenNoChangeSuppressed", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextChatSystemPromptStore(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.failNextUpsertChatIncludeDefaultSystemPrompt.Store(true) + err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "First prompt.", + IncludeDefaultSystemPrompt: ptr.Ref(false), + }) + requireSDKError(t, err, http.StatusInternalServerError) + require.Empty(t, mAudit.AuditLogs()) + + // The failed write rolled back, so the effective state is still + // the deployment default (empty prompt, include-default true). + // Repeating the same effective state through the toggle-only + // default changes nothing and must stay suppressed. + err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + require.NoError(t, err) + require.Empty(t, mAudit.AuditLogs()) + }) + + 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.ResourceTypeChatSystemPromptSettings, 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 +13062,99 @@ 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. + 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.ResourceTypeChatSystemPromptSettings, logs[0].ResourceType) + require.Equal(t, "", logs[0].ResourceTarget) + require.NotEqual(t, uuid.Nil, 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 failed PUT emits nothing. + mAudit.ResetLogs() + tooLong := strings.Repeat("a", 131073) + err = auditClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: tooLong, + }) + requireSDKError(t, err, http.StatusBadRequest) + require.Empty(t, mAudit.AuditLogs()) + }) + + // A failing upsert must not leave a stale Old behind: the request + // fails with 500 and no audit entry, and a later no-change PUT is + // still recognized as unchanged (suppressed) rather than audited as + // a change from the value the failed request had read. + t.Run("AuditFailureThenNoChangeSuppressed", 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.", + }) + requireSDKError(t, err, http.StatusInternalServerError) + require.Empty(t, mAudit.AuditLogs()) + + // The failed write rolled back, so the stored value is still + // "Persist me." and repeating it is a no-op: no entry. + err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Persist me.", + }) + require.NoError(t, err) + require.Empty(t, mAudit.AuditLogs()) + }) } //nolint:tparallel,paralleltest // Setting subtests share per-setting coderdtest instances. diff --git a/codersdk/audit.go b/codersdk/audit.go index 637410807a2..58a9bc0da8c 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" + ResourceTypeChatSystemPromptSettings ResourceType = "chat_system_prompt_settings" ) func (r ResourceType) FriendlyString() string { @@ -133,6 +134,8 @@ func (r ResourceType) FriendlyString() string { return "user secret" case ResourceTypeUserSkill: return "user skill" + case ResourceTypeChatSystemPromptSettings: + return "chat system prompt settings" default: return "unknown" } diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 136dcb50fc8..4db8d774819 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
| +| ChatSystemPromptSettings
write | |
FieldTracked
idfalse
include_default_system_prompttrue
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 6383f702336..10ce5564ad7 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_system_prompt_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 bca368d8603..4f7086ac1bc 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.ChatSystemPromptSettings{ + SystemPrompt: "old instructions", + IncludeDefaultSystemPrompt: true, + }, + right: database.ChatSystemPromptSettings{ + 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.ChatSystemPromptSettings{ + PlanModeInstructions: "old plan guidance", + }, + right: database.ChatSystemPromptSettings{ + 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.ChatSystemPromptSettings{ + SystemPrompt: "same", + IncludeDefaultSystemPrompt: true, + }, + right: database.ChatSystemPromptSettings{ + 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 a58d523d7db..65811b565a7 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}, + "ChatSystemPromptSettings": {codersdk.AuditActionWrite}, } type Action string @@ -284,6 +285,12 @@ var auditableResourcesTypes = map[any]map[string]Action{ "id": ActionIgnore, "dynamic_client_registration_enabled": ActionTrack, }, + &database.ChatSystemPromptSettings{}: { + "id": ActionIgnore, + "system_prompt": 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/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 54a1e5dfd61..5d967ec9e4a 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_system_prompt_settings" | "convert_login" | "custom_role" | "git_ssh_key" @@ -7977,6 +7978,7 @@ export const ResourceTypes: ResourceType[] = [ "ai_seat", "api_key", "chat", + "chat_system_prompt_settings", "convert_login", "custom_role", "git_ssh_key", From c83ef52489f1d194e235a79b1c05a56c28d7af0c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 30 Jul 2026 00:42:47 +0000 Subject: [PATCH 02/16] fix: keep audit observation from changing chat settings writes Review round 2 found three defects where audit observation changed endpoint behavior. Audit capture reads in both handlers now degrade best-effort: a failed Old-capture or New re-read logs a warning and skips the entry instead of aborting the request or rolling back the completed upsert, restoring byte-identical behavior with main under read failure. The write path stays the failure surface: the upserts and a new advisory lock (LockIDChatSettingsWrites) that serializes change-detection with the write still fail the request on error. The lock also closes a race where two concurrent identical PUTs each captured a stale Old and both emitted a change entry; the second transaction now sees the first's committed state and suppresses its duplicate. Handler tests pin all of it with mutation-proven regression cases; a test-only DiffOverride on InitRequest (wired through the API, nil in production) proves the fallback include-default flip reaches the audit diff with the re-read value. --- coderd/audit/request.go | 9 + coderd/coderd.go | 8 +- coderd/database/lock.go | 1 + coderd/exp_chats.go | 96 ++++++--- coderd/exp_chats_test.go | 425 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 492 insertions(+), 47 deletions(-) diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 23a88717745..2e3d14827ea 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -26,6 +26,12 @@ type RequestParams struct { Audit Auditor Log slog.Logger + // DiffOverride, when set, replaces the auditor's diff for this + // request. It exists so AGPL handler tests can pin the Old/New pair + // the handler captured, which the mock auditor's empty diff cannot + // express. Production wiring never sets it. + DiffOverride func(old, newVal any) Map + // OrganizationID is only provided when possible. If an audit resource extends // beyond the org scope, leave this as the nil uuid. OrganizationID uuid.UUID @@ -495,6 +501,9 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request if sw.Status < 400 && req.params.Action != database.AuditActionLogin && req.params.Action != database.AuditActionLogout { diff := Diff(p.Audit, req.Old, req.New) + if req.params.DiffOverride != nil { + diff = req.params.DiffOverride(req.Old, req.New) + } var err error diffRaw, err = json.Marshal(diff) diff --git a/coderd/coderd.go b/coderd/coderd.go index 3f9d3babbf8..d271f2e9666 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -2325,8 +2325,12 @@ type API struct { // This is used to associate objects with a specific // Coder API instance, like workspace agents to a // specific replica. - ID uuid.UUID - Auditor atomic.Pointer[audit.Auditor] + ID uuid.UUID + Auditor atomic.Pointer[audit.Auditor] + // chatSystemPromptAuditDiffOverride overrides the audit diff on the + // chat system instructions PUT handlers. Tests set it to pin the + // captured Old/New pair; it is nil in production. + chatSystemPromptAuditDiffOverride func(old, newVal any) audit.Map ConnectionLogger atomic.Pointer[connectionlog.ConnectionLogger] WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool] TailnetCoordinator atomic.Pointer[tailnet.Coordinator] diff --git a/coderd/database/lock.go b/coderd/database/lock.go index a9830336fed..45a97d16a76 100644 --- a/coderd/database/lock.go +++ b/coderd/database/lock.go @@ -18,6 +18,7 @@ const ( LockIDAIProvidersEnvSeed LockIDChatModelConfigWrites LockIDChatCapacityAdmission + LockIDChatSettingsWrites ) // GenLockID generates a unique and consistent lock ID from a given string. diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index a4b5cdd76d4..03c382bdfd8 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4524,6 +4524,13 @@ func parseCompactionThresholdKey(key string) (uuid.UUID, error) { return id, nil } +// SetChatSystemPromptAuditDiffOverrideForTesting sets the audit diff override +// used by the chat system instructions PUT handlers. Tests use it to pin the +// captured Old/New pair; production wiring leaves it nil. +func (api *API) SetChatSystemPromptAuditDiffOverrideForTesting(fn func(old, newVal any) audit.Map) { + api.chatSystemPromptAuditDiffOverride = fn +} + //nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. func (api *API) getChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -4554,10 +4561,11 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { } aReq, commitAudit := audit.InitRequest[database.ChatSystemPromptSettings](rw, &audit.RequestParams{ - Audit: *api.Auditor.Load(), - Log: api.Logger, - Request: r, - Action: database.AuditActionWrite, + Audit: *api.Auditor.Load(), + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + DiffOverride: api.chatSystemPromptAuditDiffOverride, }) defer commitAudit() @@ -4578,14 +4586,29 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { }) return } + // The 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 suppresses its duplicate audit entry. The lock is part of the + // write path, so a failure to acquire it fails the request. err := api.Database.InTx(func(tx database.Store) error { - oldConfig, err := tx.GetChatSystemPromptConfig(ctx) - if err != nil { - return err - } - aReq.Old = database.ChatSystemPromptSettings{ - SystemPrompt: oldConfig.ChatSystemPrompt, - IncludeDefaultSystemPrompt: oldConfig.IncludeDefaultSystemPrompt, + if err := tx.AcquireLock(ctx, database.LockIDChatSettingsWrites); err != nil { + return xerrors.Errorf("acquire chat settings write lock: %w", err) + } + + // Audit capture only: a failed read must not change the outcome + // of the request, so it degrades to an unaudited write rather + // than aborting. + oldConfig, oldErr := tx.GetChatSystemPromptConfig(ctx) + oldCaptured := oldErr == nil + if !oldCaptured { + api.Logger.Warn(ctx, "audit old capture failed, writing chat system prompt without an audit entry", + slog.Error(oldErr)) + } else { + aReq.Old = database.ChatSystemPromptSettings{ + SystemPrompt: oldConfig.ChatSystemPrompt, + IncludeDefaultSystemPrompt: oldConfig.IncludeDefaultSystemPrompt, + } } if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil { return err @@ -4600,13 +4623,20 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { return err } } + if !oldCaptured { + // Without a baseline there is no meaningful change detection. + return nil + } // Re-read the pair to build New: the effective include-default flag // is computed from the toggle row AND the current prompt, so a // prompt-only write can flip it without the request carrying the - // flag. - newConfig, err := tx.GetChatSystemPromptConfig(ctx) - if err != nil { - return err + // flag. Same best-effort rule as the Old capture: a failed re-read + // must not roll back the completed upserts. + newConfig, newErr := tx.GetChatSystemPromptConfig(ctx) + if newErr != nil { + api.Logger.Warn(ctx, "audit new capture failed, writing chat system prompt without an audit entry", + slog.Error(newErr)) + return nil } if newConfig.ChatSystemPrompt == oldConfig.ChatSystemPrompt && newConfig.IncludeDefaultSystemPrompt == oldConfig.IncludeDefaultSystemPrompt { @@ -4667,10 +4697,11 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ } aReq, commitAudit := audit.InitRequest[database.ChatSystemPromptSettings](rw, &audit.RequestParams{ - Audit: *api.Auditor.Load(), - Log: api.Logger, - Request: r, - Action: database.AuditActionWrite, + Audit: *api.Auditor.Load(), + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + DiffOverride: api.chatSystemPromptAuditDiffOverride, }) defer commitAudit() @@ -4692,21 +4723,34 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ return } + // The advisory lock serializes the audit change-detection with the + // write; see putChatSystemPrompt for the rationale. The lock is part + // of the write path, so a failure to acquire it fails the request. // The read and write share one transaction so the audited old value is // exactly the value this request replaced. err := api.Database.InTx(func(tx database.Store) error { - oldInstructions, err := tx.GetChatPlanModeInstructions(ctx) - if err != nil { - return err + if err := tx.AcquireLock(ctx, database.LockIDChatSettingsWrites); err != nil { + return xerrors.Errorf("acquire chat settings write lock: %w", err) } - aReq.Old = database.ChatSystemPromptSettings{ - PlanModeInstructions: oldInstructions, + + // Audit capture only: a failed read must not change the outcome + // of the request, so it degrades to an unaudited write rather + // than aborting. + oldInstructions, oldErr := tx.GetChatPlanModeInstructions(ctx) + if oldErr != nil { + api.Logger.Warn(ctx, "audit old capture failed, writing plan mode instructions without an audit entry", + slog.Error(oldErr)) + } else { + aReq.Old = database.ChatSystemPromptSettings{ + PlanModeInstructions: oldInstructions, + } } if err := tx.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil { return err } - if sanitizedInstructions == oldInstructions { - // Value-identical PUT: leave both audit sides without a + if oldErr != nil || sanitizedInstructions == oldInstructions { + // Without a baseline there is no meaningful change detection. + // A value-identical PUT leaves both audit sides without a // resource ID, which suppresses the audit entry entirely. // The upsert above still runs either way. return nil diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 7f0daf08eb1..f5e8484ee41 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,10 +25,12 @@ 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" + "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" agplaibridge "github.com/coder/coder/v2/aibridge" "github.com/coder/coder/v2/coderd" @@ -201,19 +204,27 @@ func findUserMessage(t testing.TB, messages []database.ChatMessage) database.Cha } // failNextChatSystemPromptStore lets a test force single chat system prompt -// queries to fail once. Flags that affect in-transaction writes are shared -// across InTx wrappers. +// 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 + 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{}, } } @@ -221,7 +232,10 @@ func newFailNextChatSystemPromptStore(store database.Store) *failNextChatSystemP 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, + Store: tx, + failNextGetChatSystemPromptConfig: s.failNextGetChatSystemPromptConfig, + armedGetChatSystemPromptConfigFailures: s.armedGetChatSystemPromptConfigFailures, + getChatSystemPromptConfigCallsBeforeFailure: s.getChatSystemPromptConfigCallsBeforeFailure, failNextUpsertChatIncludeDefaultSystemPrompt: s.failNextUpsertChatIncludeDefaultSystemPrompt, }) }, txOpts) @@ -245,9 +259,90 @@ 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) +} + +// failNextGetChatPlanModeInstructionsStore lets a test force plan-mode +// instructions reads to fail until the armed count reaches zero. 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. +type failNextGetChatPlanModeInstructionsStore struct { + database.Store + + armedGetChatPlanModeInstructionsFailures *atomic.Int64 +} + +func newFailNextGetChatPlanModeInstructionsStore(store database.Store) *failNextGetChatPlanModeInstructionsStore { + return &failNextGetChatPlanModeInstructionsStore{ + Store: store, + armedGetChatPlanModeInstructionsFailures: &atomic.Int64{}, + } +} + +func (s *failNextGetChatPlanModeInstructionsStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return function(&failNextGetChatPlanModeInstructionsStore{ + Store: tx, + armedGetChatPlanModeInstructionsFailures: s.armedGetChatPlanModeInstructionsFailures, + }) + }, txOpts) +} + +func (s *failNextGetChatPlanModeInstructionsStore) GetChatPlanModeInstructions(ctx context.Context) (string, error) { + if s.armedGetChatPlanModeInstructionsFailures != nil && s.armedGetChatPlanModeInstructionsFailures.Load() > 0 { + s.armedGetChatPlanModeInstructionsFailures.Add(-1) + return "", stderrors.New("forced plan mode instructions read failure") + } + return s.Store.GetChatPlanModeInstructions(ctx) +} + // failNextUpsertChatPlanModeInstructionsStore lets a test force the plan-mode // instructions upsert to fail once, sharing its failure state across InTx // wrappers. @@ -280,6 +375,31 @@ func (s *failNextUpsertChatPlanModeInstructionsStore) UpsertChatPlanModeInstruct return s.Store.UpsertChatPlanModeInstructions(ctx, instructions) } +// recordingSink captures slog entries so tests can assert on messages the +// handler logs, e.g. best-effort audit capture failures. +type recordingSink struct { + mu sync.Mutex + entries []slog.SinkEntry +} + +func (s *recordingSink) LogEntry(_ context.Context, e slog.SinkEntry) { + s.mu.Lock() + defer s.mu.Unlock() + s.entries = append(s.entries, e) +} + +func (*recordingSink) Sync() {} + +func (s *recordingSink) messages() []string { + s.mu.Lock() + defer s.mu.Unlock() + msgs := make([]string, len(s.entries)) + for i, e := range s.entries { + msgs[i] = e.Message + } + return msgs +} + // failNextUpdateChatModelConfigStore shares its failure state across InTx // wrappers so tests can force a specific in-transaction model-config update to // return sql.ErrNoRows. @@ -12893,21 +13013,23 @@ If a workspace is needed, use list_templates before create_workspace and follow require.Empty(t, mAudit.AuditLogs()) }) - // A failing write must not leave a stale Old behind: the request - // fails with 500 and no audit entry, and a later no-change PUT is - // still recognized as unchanged (suppressed) rather than audited as - // a change from the value the failed request had read. - t.Run("AuditFailureThenNoChangeSuppressed", func(t *testing.T) { + // A failing audit capture read must not change the request outcome: + // the write still happens and the response is still 204, the entry is + // just skipped. The Old-capture read fails here. + t.Run("AuditOldCaptureFailureStillWrites", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) rawDB, pubsub := dbtestutil.NewDB(t) store := newFailNextChatSystemPromptStore(rawDB) mAudit := audit.NewMock() + sink := &recordingSink{} + logger := slog.Make(sink) rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ Database: store, Pubsub: pubsub, DeploymentValues: coderdtest.DeploymentValues(t), Auditor: mAudit, + Logger: &logger, }) aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) client := codersdk.NewExperimentalClient(rawClient) @@ -12915,7 +13037,98 @@ If a workspace is needed, use list_templates before create_workspace and follow // Discard the login entry emitted by user creation. mAudit.ResetLogs() - store.failNextUpsertChatIncludeDefaultSystemPrompt.Store(true) + store.armedGetChatSystemPromptConfigFailures.Store(1) + err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Written despite the failed audit read.", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + require.NoError(t, err) + require.Empty(t, mAudit.AuditLogs()) + require.Contains(t, sink.messages(), "audit old capture failed, writing chat system prompt without an audit entry") + + resp, err := client.GetChatSystemPrompt(ctx) + require.NoError(t, err) + require.Equal(t, "Written despite the failed audit read.", resp.SystemPrompt) + require.True(t, resp.IncludeDefaultSystemPrompt) + }) + + // Isolated New-branch proof: the Old capture succeeds, the upserts + // succeed, and only the New re-read fails. The write still completes + // with 204 and the warn names the New capture. The write-scoped store + // counts GetChatSystemPromptConfig calls across InTx boundaries: the + // first failing call is the New re-read because the Old capture is the + // first call and only reads, while the upsert failure injection used + // in earlier tests aborts before any re-read. + t.Run("AuditNewCaptureDegradesViaWarn", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextChatSystemPromptStore(rawDB) + mAudit := audit.NewMock() + sink := &recordingSink{} + logger := slog.Make(sink) + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + Auditor: mAudit, + Logger: &logger, + }) + 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.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Initial prompt.", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + require.NoError(t, err) + require.Len(t, mAudit.AuditLogs(), 1) + + // The Old capture of the next PUT reads the stored "Initial + // prompt." and succeeds; the armed failure then fires on the New + // re-read only, because the Old read is the first call and the + // armed countdown starts after it. + mAudit.ResetLogs() + store.armedGetChatSystemPromptConfigFailures.Store(1) + store.getChatSystemPromptConfigCallsBeforeFailure.Store(1) + err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Changed prompt.", + }) + require.NoError(t, err) + require.Empty(t, mAudit.AuditLogs()) + require.Contains(t, sink.messages(), "audit new capture failed, writing chat system prompt without an audit entry") + + resp, err := client.GetChatSystemPrompt(ctx) + require.NoError(t, err) + require.Equal(t, "Changed prompt.", resp.SystemPrompt) + }) + + // 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), @@ -12925,8 +13138,9 @@ If a workspace is needed, use list_templates before create_workspace and follow // The failed write rolled back, so the effective state is still // the deployment default (empty prompt, include-default true). - // Repeating the same effective state through the toggle-only - // default changes nothing and must stay suppressed. + // Writing exactly that state changes nothing and must stay + // suppressed: the stale Old from the failed request must not + // survive into this one. err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ SystemPrompt: "", IncludeDefaultSystemPrompt: ptr.Ref(true), @@ -12935,6 +13149,105 @@ If a workspace is needed, use list_templates before create_workspace and follow require.Empty(t, mAudit.AuditLogs()) }) + // 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. + 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() + + 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 + // diff override on the API pins the captured Old/New pair, which the + // mock auditor's empty diff cannot express. + t.Run("AuditFallbackFlipDiffCarriesReReadValue", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + mAudit := audit.NewMock() + auditClient, api := newChatClientWithAPI(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) + // 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. + api.SetChatSystemPromptAuditDiffOverrideForTesting(func(old, newVal any) audit.Map { + oldSettings, ok := old.(database.ChatSystemPromptSettings) + assert.True(t, ok) + newSettings, ok := newVal.(database.ChatSystemPromptSettings) + 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}, + } + }) + _ = 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) @@ -13111,11 +13424,44 @@ func TestChatPlanModeInstructions(t *testing.T) { require.Empty(t, mAudit.AuditLogs()) }) - // A failing upsert must not leave a stale Old behind: the request - // fails with 500 and no audit entry, and a later no-change PUT is - // still recognized as unchanged (suppressed) rather than audited as - // a change from the value the failed request had read. - t.Run("AuditFailureThenNoChangeSuppressed", func(t *testing.T) { + // A failing audit old-capture read must not change the request + // outcome: the write still happens and the response is still 204, + // the entry is just skipped. + t.Run("AuditOldCaptureFailureStillWrites", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextGetChatPlanModeInstructionsStore(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.armedGetChatPlanModeInstructionsFailures.Store(1) + err := client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Written despite the failed audit read.", + }) + require.NoError(t, err) + require.Empty(t, mAudit.AuditLogs()) + + resp, err := client.GetChatPlanModeInstructions(ctx) + require.NoError(t, err) + require.Equal(t, "Written despite the failed audit read.", resp.PlanModeInstructions) + }) + + // 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) @@ -13148,13 +13494,54 @@ func TestChatPlanModeInstructions(t *testing.T) { require.Empty(t, mAudit.AuditLogs()) // The failed write rolled back, so the stored value is still - // "Persist me." and repeating it is a no-op: no entry. + // "Persist me." and repeating it is a no-op: no entry. The stale + // Old from the failed request must not survive into this one. err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ PlanModeInstructions: "Persist me.", }) require.NoError(t, err) require.Empty(t, mAudit.AuditLogs()) }) + + // 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. + 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() + + 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.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Concurrent instructions.", + }) + }() + } + 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) + }) } //nolint:tparallel,paralleltest // Setting subtests share per-setting coderdtest instances. From 79d62a26b91d4d44191b559b9b0eaa4d86cb5657 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 30 Jul 2026 01:35:57 +0000 Subject: [PATCH 03/16] fix: move audit diff pinning into the mock auditor Round 2 review ruled the DiffOverride seam a production-exported audit bypass: RequestParams.DiffOverride and the API setter were exported in normal builds and could replace or fabricate audit diffs for any audited resource. The seam is removed entirely from production code (RequestParams field, the InitRequest commit branch, the API field and setter), and the assertion support moves into the test double: MockAuditor gains a construction-time NewMockWithDiffFn whose supplied function computes the entry's diff, so the fallback-flip test pins the captured Old/New pair without any production-code hook. The flip test still dies under the stale-New mutation (re-proven). --- coderd/audit/audit.go | 19 +++++++++++++++++-- coderd/audit/request.go | 9 --------- coderd/coderd.go | 8 ++------ coderd/exp_chats.go | 25 ++++++++----------------- coderd/exp_chats_test.go | 13 ++++++------- 5 files changed, 33 insertions(+), 41 deletions(-) diff --git a/coderd/audit/audit.go b/coderd/audit/audit.go index 2b3a34d3a8f..2b1a60dc67c 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/request.go b/coderd/audit/request.go index 2e3d14827ea..23a88717745 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -26,12 +26,6 @@ type RequestParams struct { Audit Auditor Log slog.Logger - // DiffOverride, when set, replaces the auditor's diff for this - // request. It exists so AGPL handler tests can pin the Old/New pair - // the handler captured, which the mock auditor's empty diff cannot - // express. Production wiring never sets it. - DiffOverride func(old, newVal any) Map - // OrganizationID is only provided when possible. If an audit resource extends // beyond the org scope, leave this as the nil uuid. OrganizationID uuid.UUID @@ -501,9 +495,6 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request if sw.Status < 400 && req.params.Action != database.AuditActionLogin && req.params.Action != database.AuditActionLogout { diff := Diff(p.Audit, req.Old, req.New) - if req.params.DiffOverride != nil { - diff = req.params.DiffOverride(req.Old, req.New) - } var err error diffRaw, err = json.Marshal(diff) diff --git a/coderd/coderd.go b/coderd/coderd.go index d271f2e9666..3f9d3babbf8 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -2325,12 +2325,8 @@ type API struct { // This is used to associate objects with a specific // Coder API instance, like workspace agents to a // specific replica. - ID uuid.UUID - Auditor atomic.Pointer[audit.Auditor] - // chatSystemPromptAuditDiffOverride overrides the audit diff on the - // chat system instructions PUT handlers. Tests set it to pin the - // captured Old/New pair; it is nil in production. - chatSystemPromptAuditDiffOverride func(old, newVal any) audit.Map + ID uuid.UUID + Auditor atomic.Pointer[audit.Auditor] ConnectionLogger atomic.Pointer[connectionlog.ConnectionLogger] WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool] TailnetCoordinator atomic.Pointer[tailnet.Coordinator] diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 03c382bdfd8..d193734d8a1 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4524,13 +4524,6 @@ func parseCompactionThresholdKey(key string) (uuid.UUID, error) { return id, nil } -// SetChatSystemPromptAuditDiffOverrideForTesting sets the audit diff override -// used by the chat system instructions PUT handlers. Tests use it to pin the -// captured Old/New pair; production wiring leaves it nil. -func (api *API) SetChatSystemPromptAuditDiffOverrideForTesting(fn func(old, newVal any) audit.Map) { - api.chatSystemPromptAuditDiffOverride = fn -} - //nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. func (api *API) getChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -4561,11 +4554,10 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { } aReq, commitAudit := audit.InitRequest[database.ChatSystemPromptSettings](rw, &audit.RequestParams{ - Audit: *api.Auditor.Load(), - Log: api.Logger, - Request: r, - Action: database.AuditActionWrite, - DiffOverride: api.chatSystemPromptAuditDiffOverride, + Audit: *api.Auditor.Load(), + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, }) defer commitAudit() @@ -4697,11 +4689,10 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ } aReq, commitAudit := audit.InitRequest[database.ChatSystemPromptSettings](rw, &audit.RequestParams{ - Audit: *api.Auditor.Load(), - Log: api.Logger, - Request: r, - Action: database.AuditActionWrite, - DiffOverride: api.chatSystemPromptAuditDiffOverride, + Audit: *api.Auditor.Load(), + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, }) defer commitAudit() diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index f5e8484ee41..05642f57caa 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -13193,19 +13193,15 @@ If a workspace is needed, use list_templates before create_workspace and follow // 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 - // diff override on the API pins the captured Old/New pair, which the - // mock auditor's empty diff cannot express. + // 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) - mAudit := audit.NewMock() - auditClient, api := newChatClientWithAPI(t, func(opts *coderdtest.Options) { - opts.Auditor = mAudit - }) // 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. - api.SetChatSystemPromptAuditDiffOverrideForTesting(func(old, newVal any) audit.Map { + mAudit := audit.NewMockWithDiffFn(func(old, newVal any) audit.Map { oldSettings, ok := old.(database.ChatSystemPromptSettings) assert.True(t, ok) newSettings, ok := newVal.(database.ChatSystemPromptSettings) @@ -13215,6 +13211,9 @@ If a workspace is needed, use list_templates before create_workspace and follow "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() From aba957891f9ce5f9962ce1e743d7eed47c3f439d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 30 Jul 2026 10:11:31 +0000 Subject: [PATCH 04/16] fix: restore pre-transaction 500 detail on plan-mode write failure Wrapping the previously non-transactional plan-mode upsert in InTx changed the error response body: main's detail was the raw write error, the wrapped version's was "execute transaction: ". The handler now records the callback's write error and responds from it exactly as main did, keeping the InTx wrapper only for lock, begin, commit and rollback failures so those stay distinguishable. The existing rollback regression test pins the raw detail, mutation-proven by restoring the wrapper. --- coderd/exp_chats.go | 15 ++++++++++++++- coderd/exp_chats_test.go | 5 ++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index d193734d8a1..695b950866f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4719,6 +4719,7 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ // of the write path, so a failure to acquire it fails the request. // The read and write share one transaction so the audited old value is // exactly the value this request replaced. + var writeErr error err := api.Database.InTx(func(tx database.Store) error { if err := tx.AcquireLock(ctx, database.LockIDChatSettingsWrites); err != nil { return xerrors.Errorf("acquire chat settings write lock: %w", err) @@ -4737,6 +4738,11 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ } } if err := tx.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil { + // Record the raw write error so the response matches the + // pre-transaction behavior exactly: InTx wraps callback + // errors in "execute transaction", which would otherwise leak + // into the response detail. + writeErr = err return err } if oldErr != nil || sanitizedInstructions == oldInstructions { @@ -4755,9 +4761,16 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ return nil }, nil) if err != nil { + // A write failure responds with the raw error exactly as the + // pre-transaction endpoint did; lock, begin, commit and rollback + // failures keep the InTx wrapper so they stay distinguishable. + detail := err.Error() + if writeErr != nil { + detail = writeErr.Error() + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error updating plan mode instructions.", - Detail: err.Error(), + Detail: detail, }) return } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 05642f57caa..4cb3bf16c42 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -13489,7 +13489,10 @@ func TestChatPlanModeInstructions(t *testing.T) { err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ PlanModeInstructions: "Never stored.", }) - requireSDKError(t, err, http.StatusInternalServerError) + 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) require.Empty(t, mAudit.AuditLogs()) // The failed write rolled back, so the stored value is still From eff5abc64116a8238072d5409ef21ead544d3e08 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 30 Jul 2026 10:40:21 +0000 Subject: [PATCH 05/16] fix: audit the stored plan-mode value and renumber migration to 000562 The plan-mode handler built its audit New from request-derived text, contradicting the rule the system-prompt endpoint already follows: the write may normalize the value, so request-derived text can misreport the change. It now re-reads the stored value after the upsert inside the same transaction and uses that read for both New and the change comparison, with the same best-effort degradation on a failed re-read. A normalizing test store proves the audited New is the stored value, mutation-proven by restoring the request-derived assignment. Also renumber the enum migration off the 000561 collision main took. --- ...udit_chat_system_prompt_settings.down.sql} | 0 ..._audit_chat_system_prompt_settings.up.sql} | 0 coderd/exp_chats.go | 18 ++++- coderd/exp_chats_test.go | 65 +++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) rename coderd/database/migrations/{000561_audit_chat_system_prompt_settings.down.sql => 000562_audit_chat_system_prompt_settings.down.sql} (100%) rename coderd/database/migrations/{000561_audit_chat_system_prompt_settings.up.sql => 000562_audit_chat_system_prompt_settings.up.sql} (100%) diff --git a/coderd/database/migrations/000561_audit_chat_system_prompt_settings.down.sql b/coderd/database/migrations/000562_audit_chat_system_prompt_settings.down.sql similarity index 100% rename from coderd/database/migrations/000561_audit_chat_system_prompt_settings.down.sql rename to coderd/database/migrations/000562_audit_chat_system_prompt_settings.down.sql diff --git a/coderd/database/migrations/000561_audit_chat_system_prompt_settings.up.sql b/coderd/database/migrations/000562_audit_chat_system_prompt_settings.up.sql similarity index 100% rename from coderd/database/migrations/000561_audit_chat_system_prompt_settings.up.sql rename to coderd/database/migrations/000562_audit_chat_system_prompt_settings.up.sql diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 695b950866f..a1a1a23194f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4745,8 +4745,22 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ writeErr = err return err } - if oldErr != nil || sanitizedInstructions == oldInstructions { + if oldErr != nil { // Without a baseline there is no meaningful change detection. + return nil + } + // Re-read the stored value to build New and to compare: the write + // may normalize the value, so request-derived text can + // misreport the change. Same best-effort rule as the Old + // capture: a failed re-read must not roll back the completed + // upsert. + newInstructions, newErr := tx.GetChatPlanModeInstructions(ctx) + if newErr != nil { + api.Logger.Warn(ctx, "audit new capture failed, writing plan mode instructions without an audit entry", + slog.Error(newErr)) + return nil + } + if newInstructions == oldInstructions { // A value-identical PUT leaves both audit sides without a // resource ID, which suppresses the audit entry entirely. // The upsert above still runs either way. @@ -4756,7 +4770,7 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ // entry shows the old-to-new transition. aReq.New = database.ChatSystemPromptSettings{ ID: uuid.New(), - PlanModeInstructions: sanitizedInstructions, + PlanModeInstructions: newInstructions, } return nil }, nil) diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 4cb3bf16c42..1c3dacadc15 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -375,6 +375,23 @@ func (s *failNextUpsertChatPlanModeInstructionsStore) UpsertChatPlanModeInstruct return s.Store.UpsertChatPlanModeInstructions(ctx, instructions) } +// normalizingChatPlanModeInstructionsStore stores an uppercased value on +// every upsert, so tests can prove the audited New comes from the stored +// value, not the request text. +type normalizingChatPlanModeInstructionsStore struct { + database.Store +} + +func (s *normalizingChatPlanModeInstructionsStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return function(&normalizingChatPlanModeInstructionsStore{Store: tx}) + }, txOpts) +} + +func (s *normalizingChatPlanModeInstructionsStore) UpsertChatPlanModeInstructions(ctx context.Context, instructions string) error { + return s.Store.UpsertChatPlanModeInstructions(ctx, strings.ToUpper(instructions)) +} + // recordingSink captures slog entries so tests can assert on messages the // handler logs, e.g. best-effort audit capture failures. type recordingSink struct { @@ -13456,6 +13473,54 @@ func TestChatPlanModeInstructions(t *testing.T) { require.Equal(t, "Written despite the failed audit read.", resp.PlanModeInstructions) }) + // The audited New must be the STORED value, not the request text: the + // write may normalize the value, and request-derived text would + // misreport the change. The store uppercases every upsert; the entry + // must carry the stored uppercase form. + t.Run("AuditNewIsStoredValue", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := &normalizingChatPlanModeInstructionsStore{Store: rawDB} + mAudit := audit.NewMockWithDiffFn(func(old, newVal any) audit.Map { + oldSettings, ok := old.(database.ChatSystemPromptSettings) + assert.True(t, ok) + newSettings, ok := newVal.(database.ChatSystemPromptSettings) + assert.True(t, ok) + return audit.Map{ + "plan_mode_instructions": {Old: oldSettings.PlanModeInstructions, New: newSettings.PlanModeInstructions}, + } + }) + 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: "normalize me", + }) + 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{ + "plan_mode_instructions": {Old: "", New: "NORMALIZE ME"}, + }, diff) + + resp, err := client.GetChatPlanModeInstructions(ctx) + require.NoError(t, err) + require.Equal(t, "NORMALIZE ME", resp.PlanModeInstructions) + }) + // 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 From 8db0707fa62589917ee363241318d313ca41a1cc Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 30 Jul 2026 12:37:41 +0000 Subject: [PATCH 06/16] feat: record chat instruction setting events with stable identity Round 3 review found the audit machinery itself could change what a member sees: the advisory lock and the plan-mode transaction exist only for auditing, and their failures replaced main's successful response. The write path is now authoritative on both endpoints: on any audit-machinery failure (lock, begin, commit, rollback) the handler runs main's idempotent write path directly and derives the response from it, while write failures keep the exact response the endpoint produced before the audit wiring existed (transaction error for the system prompt, which was always transactional; the raw write error for plan mode, which was not). The full InTx error is logged before the response detail is chosen so rollback failures cannot vanish. Accepted consequence: when the lock cannot be taken, two concurrent identical writes can both record, which is audit degradation, not a behavior change. Operator decision D5 lands in the same change. The resource type is renamed chat_instruction_settings (Go type ChatInstructionSettings, friendly string, frontend filter label) because it carries the plan-mode instructions too. Each endpoint now has a fixed resource ID and a human-readable target ("System prompt", "Plan mode instructions"), so history-by-setting works; identity is assigned before authorization, so denied and failed PUTs record the attempt with the real status and an empty diff, and capture-degraded writes record instead of going silent, distinguishing "nothing changed" from "something changed and capture degraded". No-op suppression moves off the nil-ID skip to InitRequestWithCancel, which also removes the trap where setting Old.ID would have emitted a row claiming every field was cleared. Migration renumbered to 000562 (main took 000561). --- coderd/apidoc/docs.go | 4 +- coderd/apidoc/swagger.json | 4 +- coderd/audit/diff.go | 2 +- coderd/audit/request.go | 34 ++- coderd/database/dump.sql | 2 +- ..._audit_chat_instruction_settings.down.sql} | 0 ...562_audit_chat_instruction_settings.up.sql | 2 + ...2_audit_chat_system_prompt_settings.up.sql | 2 - coderd/database/models.go | 6 +- coderd/database/types.go | 17 +- coderd/exp_chats.go | 203 +++++++------ coderd/exp_chats_test.go | 278 ++++++++++++++++-- codersdk/audit.go | 28 +- docs/admin/security/audit-logs.md | 2 +- docs/reference/api/schemas.md | 6 +- enterprise/audit/diff_internal_test.go | 12 +- enterprise/audit/table.go | 5 +- site/src/api/typesGenerated.ts | 4 +- site/src/pages/AuditPage/AuditFilter.tsx | 4 + 19 files changed, 453 insertions(+), 162 deletions(-) rename coderd/database/migrations/{000562_audit_chat_system_prompt_settings.down.sql => 000562_audit_chat_instruction_settings.down.sql} (100%) create mode 100644 coderd/database/migrations/000562_audit_chat_instruction_settings.up.sql delete mode 100644 coderd/database/migrations/000562_audit_chat_system_prompt_settings.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 82be26e32ce..df33e539ab4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -24306,7 +24306,7 @@ const docTemplate = `{ "chat", "user_secret", "user_skill", - "chat_system_prompt_settings" + "chat_instruction_settings" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -24345,7 +24345,7 @@ const docTemplate = `{ "ResourceTypeChat", "ResourceTypeUserSecret", "ResourceTypeUserSkill", - "ResourceTypeChatSystemPromptSettings" + "ResourceTypeChatInstructionSettings" ] }, "codersdk.Response": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index be9872700bf..c052f8d023b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -22288,7 +22288,7 @@ "chat", "user_secret", "user_skill", - "chat_system_prompt_settings" + "chat_instruction_settings" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -22327,7 +22327,7 @@ "ResourceTypeChat", "ResourceTypeUserSecret", "ResourceTypeUserSkill", - "ResourceTypeChatSystemPromptSettings" + "ResourceTypeChatInstructionSettings" ] }, "codersdk.Response": { diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 3ba5373be01..97105c24d54 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -43,7 +43,7 @@ type Auditable interface { database.AuditableUserAIBudgetOverride | database.UserSecret | database.UserSkill | - database.ChatSystemPromptSettings + 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 23a88717745..9aaafb1e2cc 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -158,9 +158,8 @@ func ResourceTarget[T Auditable](tgt T) string { return typed.Name case database.UserSkill: return typed.Name - case database.ChatSystemPromptSettings: - // Deployment singleton, no target. - return "" + case database.ChatInstructionSettings: + return typed.Name default: panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt)) } @@ -171,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: @@ -246,8 +261,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { return typed.ID case database.UserSkill: return typed.ID - case database.ChatSystemPromptSettings: - // Artificial ID for auditing purposes. + case database.ChatInstructionSettings: + // Fixed ID per setting; see ChatInstructionSettings IDs. return typed.ID default: panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt)) @@ -324,8 +339,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeUserSecret case database.UserSkill: return database.ResourceTypeUserSkill - case database.ChatSystemPromptSettings: - return database.ResourceTypeChatSystemPromptSettings + case database.ChatInstructionSettings: + return database.ResourceTypeChatInstructionSettings default: panic(fmt.Sprintf("unknown resource %T for ResourceType", typed)) } @@ -416,9 +431,8 @@ func ResourceRequiresOrgID[T Auditable]() bool { case database.UserSkill: // User skills are global to the user across organizations. return false - case database.ChatSystemPromptSettings: - // Artificial ID for auditing purposes. This is a deployment - // singleton, not scoped to any organization. + 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 5517bd66f8f..ee444df9169 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -599,7 +599,7 @@ CREATE TYPE resource_type AS ENUM ( 'ai_gateway_key', 'user_ai_budget_override', 'oauth2_provider_settings', - 'chat_system_prompt_settings' + 'chat_instruction_settings' ); CREATE TYPE shareable_workspace_owners AS ENUM ( diff --git a/coderd/database/migrations/000562_audit_chat_system_prompt_settings.down.sql b/coderd/database/migrations/000562_audit_chat_instruction_settings.down.sql similarity index 100% rename from coderd/database/migrations/000562_audit_chat_system_prompt_settings.down.sql rename to coderd/database/migrations/000562_audit_chat_instruction_settings.down.sql diff --git a/coderd/database/migrations/000562_audit_chat_instruction_settings.up.sql b/coderd/database/migrations/000562_audit_chat_instruction_settings.up.sql new file mode 100644 index 00000000000..aa03609c738 --- /dev/null +++ b/coderd/database/migrations/000562_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/migrations/000562_audit_chat_system_prompt_settings.up.sql b/coderd/database/migrations/000562_audit_chat_system_prompt_settings.up.sql deleted file mode 100644 index 2547d1b7f95..00000000000 --- a/coderd/database/migrations/000562_audit_chat_system_prompt_settings.up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TYPE resource_type - ADD VALUE IF NOT EXISTS 'chat_system_prompt_settings'; diff --git a/coderd/database/models.go b/coderd/database/models.go index db2601220c0..cff3b4c6bf4 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3535,7 +3535,7 @@ const ( ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key" ResourceTypeUserAIBudgetOverride ResourceType = "user_ai_budget_override" ResourceTypeOauth2ProviderSettings ResourceType = "oauth2_provider_settings" - ResourceTypeChatSystemPromptSettings ResourceType = "chat_system_prompt_settings" + ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings" ) func (e *ResourceType) Scan(src interface{}) error { @@ -3611,7 +3611,7 @@ func (e ResourceType) Valid() bool { ResourceTypeAIGatewayKey, ResourceTypeUserAIBudgetOverride, ResourceTypeOauth2ProviderSettings, - ResourceTypeChatSystemPromptSettings: + ResourceTypeChatInstructionSettings: return true } return false @@ -3655,7 +3655,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeAIGatewayKey, ResourceTypeUserAIBudgetOverride, ResourceTypeOauth2ProviderSettings, - ResourceTypeChatSystemPromptSettings, + ResourceTypeChatInstructionSettings, } } diff --git a/coderd/database/types.go b/coderd/database/types.go index 001f17ff8cf..17dc65827a3 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -49,17 +49,20 @@ type OAuth2ProviderSettings struct { DynamicClientRegistrationEnabled bool `db:"dynamic_client_registration_enabled" json:"dynamic_client_registration_enabled"` } -// ChatSystemPromptSettings is the auditable shape of the deployment-wide -// chat system prompt configuration, stored across the +// 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 ChatSystemPromptSettings struct { - ID uuid.UUID `db:"id" json:"id"` - SystemPrompt string `db:"system_prompt" json:"system_prompt"` - IncludeDefaultSystemPrompt bool `db:"include_default_system_prompt" json:"include_default_system_prompt"` - PlanModeInstructions string `db:"plan_mode_instructions" json:"plan_mode_instructions"` +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"` + 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 diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index a1a1a23194f..6fc60942837 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4548,18 +4548,28 @@ func (api *API) getChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { - httpapi.Forbidden(rw) - return - } - aReq, commitAudit := audit.InitRequest[database.ChatSystemPromptSettings](rw, &audit.RequestParams{ + // 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() + 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. @@ -4578,31 +4588,29 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { }) return } + + var writeErr error // The 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 suppresses its duplicate audit entry. The lock is part of the - // write path, so a failure to acquire it fails the request. + // and cancels its duplicate audit entry. err := api.Database.InTx(func(tx database.Store) error { if err := tx.AcquireLock(ctx, database.LockIDChatSettingsWrites); err != nil { return xerrors.Errorf("acquire chat settings write lock: %w", err) } - // Audit capture only: a failed read must not change the outcome - // of the request, so it degrades to an unaudited write rather - // than aborting. + // Audit capture only: a failed read does not change the outcome + // of the request; the row exports with an empty diff instead. oldConfig, oldErr := tx.GetChatSystemPromptConfig(ctx) - oldCaptured := oldErr == nil - if !oldCaptured { - api.Logger.Warn(ctx, "audit old capture failed, writing chat system prompt without an audit entry", + if oldErr != nil { + api.Logger.Warn(ctx, "audit old capture failed, writing chat system prompt without a diff", slog.Error(oldErr)) } else { - aReq.Old = database.ChatSystemPromptSettings{ - SystemPrompt: oldConfig.ChatSystemPrompt, - IncludeDefaultSystemPrompt: oldConfig.IncludeDefaultSystemPrompt, - } + aReq.Old.SystemPrompt = oldConfig.ChatSystemPrompt + aReq.Old.IncludeDefaultSystemPrompt = oldConfig.IncludeDefaultSystemPrompt } if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil { + writeErr = err return err } // Only update the include-default flag when the caller explicitly @@ -4612,46 +4620,72 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { // that only send system_prompt. if req.IncludeDefaultSystemPrompt != nil { if err := tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt); err != nil { + writeErr = err return err } } - if !oldCaptured { + if oldErr != nil { // Without a baseline there is no meaningful change detection. return nil } // Re-read the pair to build New: the effective include-default flag // is computed from the toggle row AND the current prompt, so a // prompt-only write can flip it without the request carrying the - // flag. Same best-effort rule as the Old capture: a failed re-read - // must not roll back the completed upserts. + // flag. Same best-effort rule as the Old capture. newConfig, newErr := tx.GetChatSystemPromptConfig(ctx) if newErr != nil { - api.Logger.Warn(ctx, "audit new capture failed, writing chat system prompt without an audit entry", + api.Logger.Warn(ctx, "audit new capture failed, writing chat system prompt without a diff", slog.Error(newErr)) return nil } if newConfig.ChatSystemPrompt == oldConfig.ChatSystemPrompt && newConfig.IncludeDefaultSystemPrompt == oldConfig.IncludeDefaultSystemPrompt { - // Value-identical PUT: leave both audit sides without a - // resource ID, which suppresses the audit entry entirely. - // The upserts above still run either way. + // Value-identical PUT: cancel the entry entirely. The + // upserts above still run either way. + commitAudit(false) return nil } - // Artificial ID for auditing purposes, set only on New so the - // entry shows the old-to-new transition. - aReq.New = database.ChatSystemPromptSettings{ - ID: uuid.New(), - SystemPrompt: newConfig.ChatSystemPrompt, - IncludeDefaultSystemPrompt: newConfig.IncludeDefaultSystemPrompt, - } + aReq.New.SystemPrompt = newConfig.ChatSystemPrompt + aReq.New.IncludeDefaultSystemPrompt = newConfig.IncludeDefaultSystemPrompt return nil }, nil) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error updating chat system prompt configuration.", - Detail: err.Error(), - }) - return + // Log the full InTx error first: lock, commit and rollback detail + // would otherwise vanish from every observable surface. + api.Logger.Warn(ctx, "chat system prompt update transaction failed", + slog.Error(err)) + if writeErr != nil { + // The write itself failed: this endpoint was transactional + // before the audit wiring existed, so the response derives + // from the transaction error exactly as it did there. + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating chat system prompt configuration.", + Detail: err.Error(), + }) + return + } + // Only the audit machinery around the write failed (lock, begin, + // commit or rollback). Main's write path stays authoritative: + // the same upserts are idempotent, so run them directly and + // derive the response from that. The attempt row exports with + // the real status and an empty diff; with the lock unusable, two + // concurrent identical writes can both record, which is accepted + // audit degradation. + 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) } @@ -4683,18 +4717,28 @@ 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() - if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { - httpapi.Forbidden(rw) - return - } - aReq, commitAudit := audit.InitRequest[database.ChatSystemPromptSettings](rw, &audit.RequestParams{ + // 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() + 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 + } // Cap the raw request body to prevent excessive memory use from // payloads padded with invisible characters that sanitize away. @@ -4714,34 +4758,24 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ return } - // The advisory lock serializes the audit change-detection with the - // write; see putChatSystemPrompt for the rationale. The lock is part - // of the write path, so a failure to acquire it fails the request. - // The read and write share one transaction so the audited old value is - // exactly the value this request replaced. var writeErr error + // The advisory lock serializes the audit change-detection with the + // write; see putChatSystemPrompt for the rationale. err := api.Database.InTx(func(tx database.Store) error { if err := tx.AcquireLock(ctx, database.LockIDChatSettingsWrites); err != nil { return xerrors.Errorf("acquire chat settings write lock: %w", err) } - // Audit capture only: a failed read must not change the outcome - // of the request, so it degrades to an unaudited write rather - // than aborting. + // Audit capture only: a failed read does not change the outcome + // of the request; the row exports with an empty diff instead. oldInstructions, oldErr := tx.GetChatPlanModeInstructions(ctx) if oldErr != nil { - api.Logger.Warn(ctx, "audit old capture failed, writing plan mode instructions without an audit entry", + api.Logger.Warn(ctx, "audit old capture failed, writing plan mode instructions without a diff", slog.Error(oldErr)) } else { - aReq.Old = database.ChatSystemPromptSettings{ - PlanModeInstructions: oldInstructions, - } + aReq.Old.PlanModeInstructions = oldInstructions } if err := tx.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil { - // Record the raw write error so the response matches the - // pre-transaction behavior exactly: InTx wraps callback - // errors in "execute transaction", which would otherwise leak - // into the response detail. writeErr = err return err } @@ -4752,43 +4786,48 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ // Re-read the stored value to build New and to compare: the write // may normalize the value, so request-derived text can // misreport the change. Same best-effort rule as the Old - // capture: a failed re-read must not roll back the completed - // upsert. + // capture. newInstructions, newErr := tx.GetChatPlanModeInstructions(ctx) if newErr != nil { - api.Logger.Warn(ctx, "audit new capture failed, writing plan mode instructions without an audit entry", + api.Logger.Warn(ctx, "audit new capture failed, writing plan mode instructions without a diff", slog.Error(newErr)) return nil } if newInstructions == oldInstructions { - // A value-identical PUT leaves both audit sides without a - // resource ID, which suppresses the audit entry entirely. - // The upsert above still runs either way. + // Value-identical PUT: cancel the entry entirely. The + // upsert above still runs either way. + commitAudit(false) return nil } - // Artificial ID for auditing purposes, set only on New so the - // entry shows the old-to-new transition. - aReq.New = database.ChatSystemPromptSettings{ - ID: uuid.New(), - PlanModeInstructions: newInstructions, - } + aReq.New.PlanModeInstructions = newInstructions return nil }, nil) if err != nil { - // A write failure responds with the raw error exactly as the - // pre-transaction endpoint did; lock, begin, commit and rollback - // failures keep the InTx wrapper so they stay distinguishable. - detail := err.Error() + // Log the full InTx error first: the response below derives from + // the write error alone, so lock, commit and rollback detail + // would otherwise vanish from every observable surface. + api.Logger.Warn(ctx, "plan mode instructions update transaction failed", + slog.Error(err)) + if writeErr == nil { + // The audit machinery around the write failed (lock, begin, + // commit or rollback), not the write itself. Main's write + // path stays authoritative: the upsert is idempotent, so + // run it directly exactly as the endpoint did before the + // audit wiring existed, and derive the response from that. + // The attempt row exports with the real status and an empty + // diff; with the lock unusable, two concurrent identical + // writes can both record, which is accepted audit + // degradation. + writeErr = api.Database.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions) + } if writeErr != nil { - detail = writeErr.Error() + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating plan mode instructions.", + Detail: writeErr.Error(), + }) + return } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error updating plan mode instructions.", - Detail: detail, - }) - return } - rw.WriteHeader(http.StatusNoContent) } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 1c3dacadc15..06f97e5144e 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -375,6 +375,38 @@ func (s *failNextUpsertChatPlanModeInstructionsStore) UpsertChatPlanModeInstruct return s.Store.UpsertChatPlanModeInstructions(ctx, instructions) } +// failNextChatSettingsLockStore lets a test force the chat settings advisory +// lock acquisition to fail once, sharing its failure state across InTx +// wrappers. +type failNextChatSettingsLockStore struct { + database.Store + + failNextAcquireLock *atomic.Bool +} + +func newFailNextChatSettingsLockStore(store database.Store) *failNextChatSettingsLockStore { + return &failNextChatSettingsLockStore{ + Store: store, + failNextAcquireLock: &atomic.Bool{}, + } +} + +func (s *failNextChatSettingsLockStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return function(&failNextChatSettingsLockStore{ + Store: tx, + failNextAcquireLock: s.failNextAcquireLock, + }) + }, txOpts) +} + +func (s *failNextChatSettingsLockStore) AcquireLock(ctx context.Context, id int64) error { + if s.failNextAcquireLock.CompareAndSwap(true, false) { + return stderrors.New("forced advisory lock acquisition failure") + } + return s.Store.AcquireLock(ctx, id) +} + // normalizingChatPlanModeInstructionsStore stores an uppercased value on // every upsert, so tests can prove the audited New comes from the stored // value, not the request text. @@ -12967,7 +12999,8 @@ If a workspace is needed, use list_templates before create_workspace and follow // Discard the login entry emitted by user creation. mAudit.ResetLogs() - // A value change emits a Write entry for the deployment singleton. + // 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), @@ -12977,9 +13010,9 @@ If a workspace is needed, use list_templates before create_workspace and follow logs := mAudit.AuditLogs() require.Len(t, logs, 1) require.Equal(t, database.AuditActionWrite, logs[0].Action) - require.Equal(t, database.ResourceTypeChatSystemPromptSettings, logs[0].ResourceType) - require.Equal(t, "", logs[0].ResourceTarget) - require.NotEqual(t, uuid.Nil, logs[0].ResourceID) + 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) @@ -12997,6 +13030,18 @@ If a workspace is needed, use list_templates before create_workspace and follow 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. @@ -13020,14 +13065,49 @@ If a workspace is needed, use list_templates before create_workspace and follow require.Equal(t, "Renamed without default.", resp.SystemPrompt) require.False(t, resp.IncludeDefaultSystemPrompt) - // A failed PUT emits nothing. + // 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) - require.Empty(t, mAudit.AuditLogs()) + 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 audit capture read must not change the request outcome: @@ -13060,8 +13140,11 @@ If a workspace is needed, use list_templates before create_workspace and follow IncludeDefaultSystemPrompt: ptr.Ref(true), }) require.NoError(t, err) - require.Empty(t, mAudit.AuditLogs()) - require.Contains(t, sink.messages(), "audit old capture failed, writing chat system prompt without an audit entry") + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) + require.Contains(t, sink.messages(), "audit old capture failed, writing chat system prompt without a diff") resp, err := client.GetChatSystemPrompt(ctx) require.NoError(t, err) @@ -13115,8 +13198,11 @@ If a workspace is needed, use list_templates before create_workspace and follow SystemPrompt: "Changed prompt.", }) require.NoError(t, err) - require.Empty(t, mAudit.AuditLogs()) - require.Contains(t, sink.messages(), "audit new capture failed, writing chat system prompt without an audit entry") + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) + require.Contains(t, sink.messages(), "audit new capture failed, writing chat system prompt without a diff") resp, err := client.GetChatSystemPrompt(ctx) require.NoError(t, err) @@ -13151,13 +13237,18 @@ If a workspace is needed, use list_templates before create_workspace and follow IncludeDefaultSystemPrompt: ptr.Ref(false), }) requireSDKError(t, err, http.StatusInternalServerError) - require.Empty(t, mAudit.AuditLogs()) + // 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 effective state is still // the deployment default (empty prompt, include-default true). // Writing exactly that state changes nothing and must stay // suppressed: the stale Old from the failed request must not // survive into this one. + mAudit.ResetLogs() err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ SystemPrompt: "", IncludeDefaultSystemPrompt: ptr.Ref(true), @@ -13166,6 +13257,49 @@ If a workspace is needed, use list_templates before create_workspace and follow require.Empty(t, mAudit.AuditLogs()) }) + // When the advisory lock cannot be taken, the write still happens + // through main's direct path and the response stays 204; the attempt + // exports with an empty diff. Accepted degradation: two concurrent + // identical writes can both record in this state. + t.Run("AuditLockFailureFallsBack", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextChatSettingsLockStore(rawDB) + mAudit := audit.NewMock() + sink := &recordingSink{} + logger := slog.Make(sink) + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + Auditor: mAudit, + Logger: &logger, + }) + 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: "Written despite the lock failure.", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + require.NoError(t, err) + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) + require.Contains(t, sink.messages(), "chat system prompt update transaction failed") + + resp, err := client.GetChatSystemPrompt(ctx) + require.NoError(t, err) + require.Equal(t, "Written despite the lock failure.", resp.SystemPrompt) + require.True(t, resp.IncludeDefaultSystemPrompt) + }) + // 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. @@ -13219,9 +13353,9 @@ If a workspace is needed, use list_templates before create_workspace and follow // 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.ChatSystemPromptSettings) + oldSettings, ok := old.(database.ChatInstructionSettings) assert.True(t, ok) - newSettings, ok := newVal.(database.ChatSystemPromptSettings) + newSettings, ok := newVal.(database.ChatInstructionSettings) assert.True(t, ok) return audit.Map{ "system_prompt": {Old: oldSettings.SystemPrompt, New: newSettings.SystemPrompt}, @@ -13302,7 +13436,7 @@ If a workspace is needed, use list_templates before create_workspace and follow logs := mAudit.AuditLogs() require.Len(t, logs, 1) require.Equal(t, database.AuditActionWrite, logs[0].Action) - require.Equal(t, database.ResourceTypeChatSystemPromptSettings, logs[0].ResourceType) + require.Equal(t, database.ResourceTypeChatInstructionSettings, logs[0].ResourceType) resp, err = auditClient.GetChatSystemPrompt(ctx) require.NoError(t, err) @@ -13403,7 +13537,8 @@ func TestChatPlanModeInstructions(t *testing.T) { // Discard the login entry emitted by user creation. mAudit.ResetLogs() - // A value change emits a Write entry. + // 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.", }) @@ -13412,9 +13547,10 @@ func TestChatPlanModeInstructions(t *testing.T) { logs := mAudit.AuditLogs() require.Len(t, logs, 1) require.Equal(t, database.AuditActionWrite, logs[0].Action) - require.Equal(t, database.ResourceTypeChatSystemPromptSettings, logs[0].ResourceType) - require.Equal(t, "", logs[0].ResourceTarget) - require.NotEqual(t, uuid.Nil, logs[0].ResourceID) + 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) @@ -13430,14 +13566,60 @@ func TestChatPlanModeInstructions(t *testing.T) { require.NoError(t, err) require.Equal(t, "Draft a plan first.", resp.PlanModeInstructions) - // A failed PUT emits nothing. + // 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) - require.Empty(t, mAudit.AuditLogs()) + 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 audit old-capture read must not change the request @@ -13466,7 +13648,10 @@ func TestChatPlanModeInstructions(t *testing.T) { PlanModeInstructions: "Written despite the failed audit read.", }) require.NoError(t, err) - require.Empty(t, mAudit.AuditLogs()) + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) resp, err := client.GetChatPlanModeInstructions(ctx) require.NoError(t, err) @@ -13483,9 +13668,9 @@ func TestChatPlanModeInstructions(t *testing.T) { rawDB, pubsub := dbtestutil.NewDB(t) store := &normalizingChatPlanModeInstructionsStore{Store: rawDB} mAudit := audit.NewMockWithDiffFn(func(old, newVal any) audit.Map { - oldSettings, ok := old.(database.ChatSystemPromptSettings) + oldSettings, ok := old.(database.ChatInstructionSettings) assert.True(t, ok) - newSettings, ok := newVal.(database.ChatSystemPromptSettings) + newSettings, ok := newVal.(database.ChatInstructionSettings) assert.True(t, ok) return audit.Map{ "plan_mode_instructions": {Old: oldSettings.PlanModeInstructions, New: newSettings.PlanModeInstructions}, @@ -13558,11 +13743,16 @@ func TestChatPlanModeInstructions(t *testing.T) { // 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) - require.Empty(t, mAudit.AuditLogs()) + // 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.", }) @@ -13570,6 +13760,46 @@ func TestChatPlanModeInstructions(t *testing.T) { require.Empty(t, mAudit.AuditLogs()) }) + // When the advisory lock cannot be taken, the write still happens + // through main's direct path and the response stays 204; the attempt + // exports with an empty diff. + t.Run("AuditLockFailureFallsBack", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextChatSettingsLockStore(rawDB) + mAudit := audit.NewMock() + sink := &recordingSink{} + logger := slog.Make(sink) + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + Auditor: mAudit, + Logger: &logger, + }) + 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 the lock failure.", + }) + require.NoError(t, err) + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) + require.Contains(t, sink.messages(), "plan mode instructions update transaction failed") + + resp, err := client.GetChatPlanModeInstructions(ctx) + require.NoError(t, err) + require.Equal(t, "Written despite the lock failure.", resp.PlanModeInstructions) + }) + // 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. diff --git a/codersdk/audit.go b/codersdk/audit.go index 58a9bc0da8c..7ee41ce5158 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -44,18 +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" - ResourceTypeChatSystemPromptSettings ResourceType = "chat_system_prompt_settings" + 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 { @@ -134,8 +134,8 @@ func (r ResourceType) FriendlyString() string { return "user secret" case ResourceTypeUserSkill: return "user skill" - case ResourceTypeChatSystemPromptSettings: - return "chat system prompt settings" + 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 4db8d774819..6511b104767 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -26,7 +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
| -| ChatSystemPromptSettings
write | |
FieldTracked
idfalse
include_default_system_prompttrue
plan_mode_instructionstrue
system_prompttrue
| +| ChatInstructionSettings
write | |
FieldTracked
idfalse
include_default_system_prompttrue
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 10ce5564ad7..2c20028c82f 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`, `chat_system_prompt_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` | +| 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 4f7086ac1bc..3a4eea8480f 100644 --- a/enterprise/audit/diff_internal_test.go +++ b/enterprise/audit/diff_internal_test.go @@ -512,11 +512,11 @@ func Test_diff(t *testing.T) { // endpoint populates only its two fields; the untouched // plan-mode field stays zero on both sides and never diffs. name: "SystemPromptChangeTracked", - left: database.ChatSystemPromptSettings{ + left: database.ChatInstructionSettings{ SystemPrompt: "old instructions", IncludeDefaultSystemPrompt: true, }, - right: database.ChatSystemPromptSettings{ + right: database.ChatInstructionSettings{ ID: uuid.UUID{1}, SystemPrompt: "new instructions", IncludeDefaultSystemPrompt: false, @@ -531,10 +531,10 @@ func Test_diff(t *testing.T) { // system-prompt fields stay zero on both sides, so a // plan-mode change diffs exactly one field. name: "PlanModeInstructionsChangeTracked", - left: database.ChatSystemPromptSettings{ + left: database.ChatInstructionSettings{ PlanModeInstructions: "old plan guidance", }, - right: database.ChatSystemPromptSettings{ + right: database.ChatInstructionSettings{ ID: uuid.UUID{1}, PlanModeInstructions: "new plan guidance", }, @@ -547,11 +547,11 @@ func Test_diff(t *testing.T) { // would diff empty. Handlers additionally suppress the entry // entirely by leaving both resource IDs nil. name: "ArtificialIDIgnored", - left: database.ChatSystemPromptSettings{ + left: database.ChatInstructionSettings{ SystemPrompt: "same", IncludeDefaultSystemPrompt: true, }, - right: database.ChatSystemPromptSettings{ + right: database.ChatInstructionSettings{ ID: uuid.UUID{1}, SystemPrompt: "same", IncludeDefaultSystemPrompt: true, diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 65811b565a7..eecc08aa4e9 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -37,7 +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}, - "ChatSystemPromptSettings": {codersdk.AuditActionWrite}, + "ChatInstructionSettings": {codersdk.AuditActionWrite}, } type Action string @@ -285,8 +285,9 @@ var auditableResourcesTypes = map[any]map[string]Action{ "id": ActionIgnore, "dynamic_client_registration_enabled": ActionTrack, }, - &database.ChatSystemPromptSettings{}: { + &database.ChatInstructionSettings{}: { "id": ActionIgnore, + "name": ActionIgnore, "system_prompt": ActionTrack, "include_default_system_prompt": ActionTrack, "plan_mode_instructions": ActionTrack, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5d967ec9e4a..74d8e009c6d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -7939,7 +7939,7 @@ export type ResourceType = | "ai_seat" | "api_key" | "chat" - | "chat_system_prompt_settings" + | "chat_instruction_settings" | "convert_login" | "custom_role" | "git_ssh_key" @@ -7978,7 +7978,7 @@ export const ResourceTypes: ResourceType[] = [ "ai_seat", "api_key", "chat", - "chat_system_prompt_settings", + "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 47fc09e3155..fa527dccc4c 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, From d3893c12355c3f5a500dfe6175c563db194bceaa Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 30 Jul 2026 13:02:06 +0000 Subject: [PATCH 07/16] fix: emit the ordinary entry when a fallback write succeeds The fallback rule was wrong on the post-commit position: when the audited transaction's machinery fails after Old was captured and the write succeeded, the fallback direct write lands the same value and the client sees 204, so the request is a success in every respect and gets the ordinary entry (Old to the stored New, real diff, 204). An empty diff would under-report a change that happened, and a failed entry would contradict the success the client saw. When the machinery fails before any baseline exists (lock or begin), no truthful diff is possible, so the attempt row keeps an empty diff and the real status, matching S2's documented limit with S1's D5 identity. A commit-failure test proves the ordinary entry, mutation-proven by dropping the post-fallback New population (the entry's New went empty and the test went red); a fallback-then-ordinary test proves the degraded write does not desync the next baseline. --- coderd/exp_chats.go | 64 ++++++++++++-- coderd/exp_chats_test.go | 181 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 238 insertions(+), 7 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 6fc60942837..299442e583e 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4590,6 +4590,7 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { } var writeErr error + oldCaptured := false // The 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 @@ -4606,6 +4607,7 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { api.Logger.Warn(ctx, "audit old capture failed, writing chat system prompt without a diff", slog.Error(oldErr)) } else { + oldCaptured = true aReq.Old.SystemPrompt = oldConfig.ChatSystemPrompt aReq.Old.IncludeDefaultSystemPrompt = oldConfig.IncludeDefaultSystemPrompt } @@ -4667,8 +4669,7 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { // Only the audit machinery around the write failed (lock, begin, // commit or rollback). Main's write path stays authoritative: // the same upserts are idempotent, so run them directly and - // derive the response from that. The attempt row exports with - // the real status and an empty diff; with the lock unusable, two + // derive the response from that. With the lock unusable, two // concurrent identical writes can both record, which is accepted // audit degradation. if mainErr := api.Database.InTx(func(tx database.Store) error { @@ -4686,6 +4687,33 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { }) return } + if !oldCaptured { + // The machinery failed before any baseline existed (lock or + // begin), so no truthful diff is possible: the attempt row + // exports with an empty diff and the real status. + rw.WriteHeader(http.StatusNoContent) + return + } + // The fallback succeeded, so the request is a success in every + // respect and gets the ordinary entry: re-read the stored pair + // for New (the effective include-default flag is computed from + // both rows, so request-derived text can misreport) and emit the + // real old-to-new diff. + newConfig, newErr := api.Database.GetChatSystemPromptConfig(ctx) + if newErr != nil { + api.Logger.Warn(ctx, "audit new capture failed after fallback, writing chat system prompt without a diff", + slog.Error(newErr)) + rw.WriteHeader(http.StatusNoContent) + return + } + if newConfig.ChatSystemPrompt == aReq.Old.SystemPrompt && + newConfig.IncludeDefaultSystemPrompt == aReq.Old.IncludeDefaultSystemPrompt { + commitAudit(false) + rw.WriteHeader(http.StatusNoContent) + return + } + aReq.New.SystemPrompt = newConfig.ChatSystemPrompt + aReq.New.IncludeDefaultSystemPrompt = newConfig.IncludeDefaultSystemPrompt } rw.WriteHeader(http.StatusNoContent) } @@ -4759,6 +4787,7 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ } var writeErr error + oldCaptured := false // The advisory lock serializes the audit change-detection with the // write; see putChatSystemPrompt for the rationale. err := api.Database.InTx(func(tx database.Store) error { @@ -4773,6 +4802,7 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ api.Logger.Warn(ctx, "audit old capture failed, writing plan mode instructions without a diff", slog.Error(oldErr)) } else { + oldCaptured = true aReq.Old.PlanModeInstructions = oldInstructions } if err := tx.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil { @@ -4814,10 +4844,8 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ // path stays authoritative: the upsert is idempotent, so // run it directly exactly as the endpoint did before the // audit wiring existed, and derive the response from that. - // The attempt row exports with the real status and an empty - // diff; with the lock unusable, two concurrent identical - // writes can both record, which is accepted audit - // degradation. + // With the lock unusable, two concurrent identical writes + // can both record, which is accepted audit degradation. writeErr = api.Database.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions) } if writeErr != nil { @@ -4827,6 +4855,30 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ }) return } + if !oldCaptured { + // The machinery failed before any baseline existed (lock or + // begin), so no truthful diff is possible: the attempt row + // exports with an empty diff and the real status. + rw.WriteHeader(http.StatusNoContent) + return + } + // The fallback succeeded, so the request is a success in every + // respect and gets the ordinary entry: re-read the stored value + // for New (the write may normalize it, so request-derived text + // can misreport) and emit the real old-to-new diff. + newInstructions, newErr := api.Database.GetChatPlanModeInstructions(ctx) + if newErr != nil { + api.Logger.Warn(ctx, "audit new capture failed after fallback, writing plan mode instructions without a diff", + slog.Error(newErr)) + rw.WriteHeader(http.StatusNoContent) + return + } + if newInstructions == aReq.Old.PlanModeInstructions { + commitAudit(false) + rw.WriteHeader(http.StatusNoContent) + return + } + aReq.New.PlanModeInstructions = newInstructions } rw.WriteHeader(http.StatusNoContent) } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 06f97e5144e..6e9d865d71c 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -407,7 +407,46 @@ func (s *failNextChatSettingsLockStore) AcquireLock(ctx context.Context, id int6 return s.Store.AcquireLock(ctx, id) } -// normalizingChatPlanModeInstructionsStore stores an uppercased value on +// commitFailChatPlanModeInstructionsStore runs the audited callback +// normally, then fails the transaction exactly like a commit failure: Old +// was captured, the upsert succeeded, and only the machinery completing the +// transaction failed. The fallback re-read runs outside the transaction and +// succeeds. +type commitFailChatPlanModeInstructionsStore struct { + database.Store + + // failNextCommit arms exactly one commit failure for the next audited + // transaction whose callback succeeds. + failNextCommit *atomic.Bool + // inFallback marks the direct write path so it is never failed. + inFallback *atomic.Bool +} + +func newCommitFailChatPlanModeInstructionsStore(store database.Store) *commitFailChatPlanModeInstructionsStore { + return &commitFailChatPlanModeInstructionsStore{ + Store: store, + failNextCommit: &atomic.Bool{}, + inFallback: &atomic.Bool{}, + } +} + +func (s *commitFailChatPlanModeInstructionsStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + // The fallback direct write calls the plain upsert, not InTx, so only + // the audited transaction reaches this wrapper. + return s.Store.InTx(func(tx database.Store) error { + if err := function(tx); err != nil { + return err + } + // The callback succeeded; fail completion once when armed, like a + // commit error. + if s.failNextCommit.CompareAndSwap(true, false) { + return stderrors.New("commit transaction: forced commit failure") + } + return nil + }, txOpts) +} + +// normalizingChatPlanModeInstructionsStore stores an uppercased value on// normalizingChatPlanModeInstructionsStore stores an uppercased value on // every upsert, so tests can prove the audited New comes from the stored // value, not the request text. type normalizingChatPlanModeInstructionsStore struct { @@ -13800,6 +13839,146 @@ func TestChatPlanModeInstructions(t *testing.T) { require.Equal(t, "Written despite the lock failure.", resp.PlanModeInstructions) }) + // When the transaction fails AFTER Old was captured and the write + // succeeded (a commit-time machinery failure), the fallback direct + // write lands the value and the request succeeds, so the entry is + // the ordinary one: Old to the stored New, real diff, 204. A + // degraded empty-diff row here would under-report a change that + // happened. + t.Run("AuditCommitFailureEmitsOrdinaryEntry", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newCommitFailChatPlanModeInstructionsStore(rawDB) + 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) + if oldSettings.PlanModeInstructions == newSettings.PlanModeInstructions { + return audit.Map{} + } + return audit.Map{ + "plan_mode_instructions": {Old: oldSettings.PlanModeInstructions, New: newSettings.PlanModeInstructions}, + } + }) + sink := &recordingSink{} + logger := slog.Make(sink) + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + Auditor: mAudit, + Logger: &logger, + }) + 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: "Baseline instructions.", + }) + require.NoError(t, err) + require.Len(t, mAudit.AuditLogs(), 1) + + mAudit.ResetLogs() + store.failNextCommit.Store(true) + err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Committed instructions.", + }) + require.NoError(t, err) + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) + require.Contains(t, sink.messages(), "plan mode instructions update transaction failed") + var diff map[string]codersdk.AuditDiffField + require.NoError(t, json.Unmarshal(logs[0].Diff, &diff)) + require.Equal(t, map[string]codersdk.AuditDiffField{ + "plan_mode_instructions": {Old: "Baseline instructions.", New: "Committed instructions."}, + }, diff) + + resp, err := client.GetChatPlanModeInstructions(ctx) + require.NoError(t, err) + require.Equal(t, "Committed instructions.", resp.PlanModeInstructions) + }) + + // A retry with a divergent value after a lock failure lands through + // the direct write path: the request succeeds, and because the lock + // failure meant no baseline existed, the row is the degraded one + // (real status, empty diff). A subsequent ordinary write with no + // failures then produces the ordinary old-to-new entry, proving the + // degraded write did not desync the baseline. + t.Run("AuditFallbackThenOrdinaryEntry", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextChatSettingsLockStore(rawDB) + 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) + if oldSettings.PlanModeInstructions == newSettings.PlanModeInstructions { + return audit.Map{} + } + return audit.Map{ + "plan_mode_instructions": {Old: oldSettings.PlanModeInstructions, New: newSettings.PlanModeInstructions}, + } + }) + 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: "Baseline instructions.", + }) + require.NoError(t, err) + require.Len(t, mAudit.AuditLogs(), 1) + + // Lock failure on the retry with a corrected value: no baseline + // was captured, so the row is degraded (empty diff) and the + // direct write path still lands the value. + mAudit.ResetLogs() + store.failNextAcquireLock.Store(true) + err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Corrected instructions.", + }) + require.NoError(t, err) + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) + + resp, err := client.GetChatPlanModeInstructions(ctx) + require.NoError(t, err) + require.Equal(t, "Corrected instructions.", resp.PlanModeInstructions) + + // The next ordinary write diffs against the fallback-written + // value, proving the degraded write did not desync the baseline. + mAudit.ResetLogs() + err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + PlanModeInstructions: "Third instructions.", + }) + 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{ + "plan_mode_instructions": {Old: "Corrected instructions.", New: "Third instructions."}, + }, diff) + }) + // 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. From c78d393015efa96dad302e90ce5fc5f495fa3a2e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 30 Jul 2026 14:37:44 +0000 Subject: [PATCH 08/16] fix: stop audit capture failures from fabricating deletions Two fixes from review round 2. A failed post-write New read left the audit New at its identity-only zero payload while the request succeeded, so the production enterprise differ recorded Old-to-empty and the row claimed the setting was cleared when it was not. Every degraded capture branch on both endpoints now sets New = Old before returning, so a failed audit read yields an unknown value and an empty diff, never a fabricated deletion; the rule is stated in the code comment and pinned with the production enterprise differ (the empty-diff mock is exactly why it escaped earlier rounds), mutation-proven by restoring the zero New. The include-default audited state now also carries whether the override row EXISTS, not only its effective value: writing explicit false over a legacy absent row does not move the effective boolean but inserts the persistent row and changes future behavior, so the presence field (include_default_system_prompt_set) is captured, stored in the audited struct, enumerated in the audit table, and included in the no-op comparison. The nil-to-explicit transition audits even when the effective value does not move, and explicit-false to explicit-false stays silent, both pinned with the production differ. --- coderd/database/queries.sql.go | 14 +- coderd/database/queries/siteconfig.sql | 7 +- coderd/database/types.go | 13 +- coderd/exp_chats.go | 19 ++ coderd/exp_chats_test.go | 22 +- docs/admin/security/audit-logs.md | 2 +- enterprise/audit/table.go | 11 +- .../coderd/chatinstructions_audit_test.go | 208 ++++++++++++++++++ 8 files changed, 276 insertions(+), 20 deletions(-) create mode 100644 enterprise/coderd/chatinstructions_audit_test.go diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index bf7063bbb67..39c721ce360 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 5cc902baa74..a4d9589f7da 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 17dc65827a3..cfa518c7ca2 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -59,10 +59,15 @@ 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"` - IncludeDefaultSystemPrompt bool `db:"include_default_system_prompt" json:"include_default_system_prompt"` - PlanModeInstructions string `db:"plan_mode_instructions" json:"plan_mode_instructions"` + 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 diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 299442e583e..fe3b3ce937d 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4609,6 +4609,7 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { } else { 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 { @@ -4636,11 +4637,17 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { // flag. Same best-effort rule as the Old capture. newConfig, newErr := tx.GetChatSystemPromptConfig(ctx) if newErr != nil { + // A failed audit read must yield an unknown value, never a + // zero value: leaving New at its zero payload would let the + // differ record Old-to-empty, fabricating a deletion that + // did not happen. Setting New = Old renders the diff empty. api.Logger.Warn(ctx, "audit new capture failed, writing chat system prompt without a diff", slog.Error(newErr)) + aReq.New = aReq.Old return nil } if newConfig.ChatSystemPrompt == oldConfig.ChatSystemPrompt && + newConfig.IncludeDefaultSystemPromptSet == oldConfig.IncludeDefaultSystemPromptSet && newConfig.IncludeDefaultSystemPrompt == oldConfig.IncludeDefaultSystemPrompt { // Value-identical PUT: cancel the entry entirely. The // upserts above still run either way. @@ -4648,6 +4655,7 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { return nil } aReq.New.SystemPrompt = newConfig.ChatSystemPrompt + aReq.New.IncludeDefaultSystemPromptSet = newConfig.IncludeDefaultSystemPromptSet aReq.New.IncludeDefaultSystemPrompt = newConfig.IncludeDefaultSystemPrompt return nil }, nil) @@ -4701,18 +4709,22 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { // real old-to-new diff. newConfig, newErr := api.Database.GetChatSystemPromptConfig(ctx) if newErr != nil { + // Same rule: unknown, not zero. See the in-transaction branch. api.Logger.Warn(ctx, "audit new capture failed after fallback, writing chat system prompt without a diff", slog.Error(newErr)) + aReq.New = aReq.Old rw.WriteHeader(http.StatusNoContent) return } if newConfig.ChatSystemPrompt == aReq.Old.SystemPrompt && + newConfig.IncludeDefaultSystemPromptSet == aReq.Old.IncludeDefaultSystemPromptSet && newConfig.IncludeDefaultSystemPrompt == aReq.Old.IncludeDefaultSystemPrompt { commitAudit(false) rw.WriteHeader(http.StatusNoContent) return } aReq.New.SystemPrompt = newConfig.ChatSystemPrompt + aReq.New.IncludeDefaultSystemPromptSet = newConfig.IncludeDefaultSystemPromptSet aReq.New.IncludeDefaultSystemPrompt = newConfig.IncludeDefaultSystemPrompt } rw.WriteHeader(http.StatusNoContent) @@ -4819,8 +4831,13 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ // capture. newInstructions, newErr := tx.GetChatPlanModeInstructions(ctx) if newErr != nil { + // A failed audit read must yield an unknown value, never a + // zero value: leaving New at its zero payload would let the + // differ record Old-to-empty, fabricating a deletion that + // did not happen. Setting New = Old renders the diff empty. api.Logger.Warn(ctx, "audit new capture failed, writing plan mode instructions without a diff", slog.Error(newErr)) + aReq.New = aReq.Old return nil } if newInstructions == oldInstructions { @@ -4868,8 +4885,10 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ // can misreport) and emit the real old-to-new diff. newInstructions, newErr := api.Database.GetChatPlanModeInstructions(ctx) if newErr != nil { + // Same rule: unknown, not zero. See the in-transaction branch. api.Logger.Warn(ctx, "audit new capture failed after fallback, writing plan mode instructions without a diff", slog.Error(newErr)) + aReq.New = aReq.Old rw.WriteHeader(http.StatusNoContent) return } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 6e9d865d71c..726ae93a5c7 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -13282,11 +13282,23 @@ If a workspace is needed, use list_templates before create_workspace and follow require.EqualValues(t, http.StatusInternalServerError, logs[0].StatusCode) require.JSONEq(t, "{}", string(logs[0].Diff)) - // The failed write rolled back, so the effective state is still - // the deployment default (empty prompt, include-default true). - // Writing exactly that state changes nothing and must stay - // suppressed: the stale Old from the failed request must not - // survive into this one. + // 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: "", diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 6511b104767..27082192dce 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -26,7 +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
namefalse
plan_mode_instructionstrue
system_prompttrue
| +| 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/enterprise/audit/table.go b/enterprise/audit/table.go index eecc08aa4e9..4e1082a67b1 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -286,11 +286,12 @@ var auditableResourcesTypes = map[any]map[string]Action{ "dynamic_client_registration_enabled": ActionTrack, }, &database.ChatInstructionSettings{}: { - "id": ActionIgnore, - "name": ActionIgnore, - "system_prompt": ActionTrack, - "include_default_system_prompt": ActionTrack, - "plan_mode_instructions": ActionTrack, + "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 diff --git a/enterprise/coderd/chatinstructions_audit_test.go b/enterprise/coderd/chatinstructions_audit_test.go new file mode 100644 index 00000000000..b7b7dac21ef --- /dev/null +++ b/enterprise/coderd/chatinstructions_audit_test.go @@ -0,0 +1,208 @@ +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 +} + +// failNextChatSystemPromptConfigStore fails the New re-read of the chat +// system prompt configuration once: the first read inside the audited +// transaction (the Old capture) and every later read succeed, the second +// fails. Failure state is shared across InTx wrappers. +type failNextChatSystemPromptConfigStore struct { + database.Store + + calls *atomic.Int64 + fail *atomic.Bool +} + +func newFailNextChatSystemPromptConfigStore(store database.Store) *failNextChatSystemPromptConfigStore { + return &failNextChatSystemPromptConfigStore{ + Store: store, + calls: &atomic.Int64{}, + fail: &atomic.Bool{}, + } +} + +func (s *failNextChatSystemPromptConfigStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + s.calls.Store(0) + return function(&failNextChatSystemPromptConfigStore{ + Store: tx, + calls: s.calls, + fail: s.fail, + }) + }, txOpts) +} + +func (s *failNextChatSystemPromptConfigStore) GetChatSystemPromptConfig(ctx context.Context) (database.GetChatSystemPromptConfigRow, error) { + // The second read inside the audited transaction is the New re-read. + if s.calls.Add(1) == 2 && s.fail.CompareAndSwap(true, false) { + return database.GetChatSystemPromptConfigRow{}, stderrors.New("forced new capture failure") + } + return s.Store.GetChatSystemPromptConfig(ctx) +} + +// TestChatInstructionSettingsDegradedNewCapture exercises the production +// enterprise differ against the chat instruction settings handlers, which the +// empty-diff mock auditor cannot do: a degraded New capture must yield an +// unknown value, never a zero value, so the diff is empty rather than a +// fabricated deletion. +func TestChatInstructionSettingsDegradedNewCapture(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextChatSystemPromptConfigStore(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() + + err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Baseline prompt.", + IncludeDefaultSystemPrompt: ptr.Ref(true), + }) + require.NoError(t, err) + require.Len(t, backend.entries(), 1) + + // Fail the New re-read only: the Old capture succeeds, the write + // succeeds, and the re-read fails. The row must not claim the prompt + // was cleared. + store.fail.Store(true) + err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ + SystemPrompt: "Changed prompt.", + }) + require.NoError(t, err) + require.Len(t, backend.entries(), 2) + var diff map[string]codersdk.AuditDiffField + require.NoError(t, json.Unmarshal(backend.entries()[1].Diff, &diff)) + // The unknown-value rule: the diff is empty, never a fabricated + // Old-to-empty deletion. + require.Empty(t, diff) + + resp, err := client.GetChatSystemPrompt(ctx) + require.NoError(t, err) + require.Equal(t, "Changed prompt.", resp.SystemPrompt) +} + +// 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()) +} From eb21943b06d687e2238e3209385ee4f5e44d11a7 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 30 Jul 2026 15:04:57 +0000 Subject: [PATCH 09/16] refactor: delete the audit fallback, make reads fatal, lock per setting Final structure for the audit write path, settled against a real PostgreSQL 13 instance. The best-effort premise collapses for these queries: siteconfig reads are single non-null text scans with no data-dependent conversion, and the reviewer could not make one fail while leaving the write able to succeed, so there is no reachable degraded path to defend. Audit reads (Old and New) are now fatal like usersecrets.go: the error surfaces through the endpoint's existing error path exactly as main's does. The whole fallback structure is deleted: no retried write, no direct-write path, no writeErr classification, no post-fallback re-read, no degraded rows, and the sentinel store kit and recording-sink degradation assertions that only existed to prove degradation go with it. One transaction per request, shaped like main's. The global write lock is replaced by a per-setting advisory lock taken first inside the transaction. Lock IDs derive from the exact site_configs key with GenLockID (FNV-1a 64) instead of the sequential LockID* block, so writers of different settings never contend and the IDs cannot collide with the sequential block or another subsystem's GenLockID output (the key strings are unique to these settings). LockIDChatSettingsWrites is deleted; nothing else used it. FOR UPDATE is not added: the per-key lock subsumes it and covers the absent-row case a row lock cannot. Mutation-proved by removing the plan-mode lock: two concurrent identical PUTs each captured a stale Old and both emitted, and removing it turned the suppression test red. Kept unchanged: the rename to chat_instruction_settings, the fixed per-setting identity and targets, denied-attempt rows with empty diffs, the no-fabricated-deletion guarantee (now unreachable rather than defended against), include-default presence auditing, and no-op suppression through InitRequestWithCancel taken inside the transaction. Accepted costs documented in the PR body: concurrent first writes to a not-yet-existing setting row can each record an empty Old and two identical first writes can each emit; and the added lock introduces one theoretical failure position, a lock wait exceeding the request deadline, whose only holders are sibling requests holding it for a single upsert. --- coderd/database/lock.go | 12 +- coderd/exp_chats.go | 227 ++------ coderd/exp_chats_test.go | 490 ------------------ .../coderd/chatinstructions_audit_test.go | 22 +- 4 files changed, 73 insertions(+), 678 deletions(-) diff --git a/coderd/database/lock.go b/coderd/database/lock.go index 45a97d16a76..84675889120 100644 --- a/coderd/database/lock.go +++ b/coderd/database/lock.go @@ -18,7 +18,17 @@ const ( LockIDAIProvidersEnvSeed LockIDChatModelConfigWrites LockIDChatCapacityAdmission - LockIDChatSettingsWrites +) + +// 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. diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index fe3b3ce937d..06fbc131f60 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4589,31 +4589,24 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { return } - var writeErr error - oldCaptured := false - // The 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 cancels its duplicate audit entry. + // 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 cancels its duplicate audit entry. Only writers of the + // same setting contend. err := api.Database.InTx(func(tx database.Store) error { - if err := tx.AcquireLock(ctx, database.LockIDChatSettingsWrites); err != nil { - return xerrors.Errorf("acquire chat settings write lock: %w", err) + if err := tx.AcquireLock(ctx, database.LockIDChatInstructionSystemPrompt); err != nil { + return xerrors.Errorf("acquire chat instruction setting write lock: %w", err) } - // Audit capture only: a failed read does not change the outcome - // of the request; the row exports with an empty diff instead. - oldConfig, oldErr := tx.GetChatSystemPromptConfig(ctx) - if oldErr != nil { - api.Logger.Warn(ctx, "audit old capture failed, writing chat system prompt without a diff", - slog.Error(oldErr)) - } else { - oldCaptured = true - aReq.Old.SystemPrompt = oldConfig.ChatSystemPrompt - aReq.Old.IncludeDefaultSystemPromptSet = oldConfig.IncludeDefaultSystemPromptSet - aReq.Old.IncludeDefaultSystemPrompt = oldConfig.IncludeDefaultSystemPrompt + oldConfig, err := tx.GetChatSystemPromptConfig(ctx) + if err != nil { + return err } + aReq.Old.SystemPrompt = oldConfig.ChatSystemPrompt + aReq.Old.IncludeDefaultSystemPromptSet = oldConfig.IncludeDefaultSystemPromptSet + aReq.Old.IncludeDefaultSystemPrompt = oldConfig.IncludeDefaultSystemPrompt if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil { - writeErr = err return err } // Only update the include-default flag when the caller explicitly @@ -4623,28 +4616,16 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { // that only send system_prompt. if req.IncludeDefaultSystemPrompt != nil { if err := tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt); err != nil { - writeErr = err return err } } - if oldErr != nil { - // Without a baseline there is no meaningful change detection. - return nil - } // Re-read the pair to build New: the effective include-default flag // is computed from the toggle row AND the current prompt, so a // prompt-only write can flip it without the request carrying the - // flag. Same best-effort rule as the Old capture. - newConfig, newErr := tx.GetChatSystemPromptConfig(ctx) - if newErr != nil { - // A failed audit read must yield an unknown value, never a - // zero value: leaving New at its zero payload would let the - // differ record Old-to-empty, fabricating a deletion that - // did not happen. Setting New = Old renders the diff empty. - api.Logger.Warn(ctx, "audit new capture failed, writing chat system prompt without a diff", - slog.Error(newErr)) - aReq.New = aReq.Old - return nil + // flag. + newConfig, err := tx.GetChatSystemPromptConfig(ctx) + if err != nil { + return err } if newConfig.ChatSystemPrompt == oldConfig.ChatSystemPrompt && newConfig.IncludeDefaultSystemPromptSet == oldConfig.IncludeDefaultSystemPromptSet && @@ -4660,72 +4641,11 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { return nil }, nil) if err != nil { - // Log the full InTx error first: lock, commit and rollback detail - // would otherwise vanish from every observable surface. - api.Logger.Warn(ctx, "chat system prompt update transaction failed", - slog.Error(err)) - if writeErr != nil { - // The write itself failed: this endpoint was transactional - // before the audit wiring existed, so the response derives - // from the transaction error exactly as it did there. - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error updating chat system prompt configuration.", - Detail: err.Error(), - }) - return - } - // Only the audit machinery around the write failed (lock, begin, - // commit or rollback). Main's write path stays authoritative: - // the same upserts are idempotent, so run them directly and - // derive the response from that. With the lock unusable, two - // concurrent identical writes can both record, which is accepted - // audit degradation. - 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 - } - if !oldCaptured { - // The machinery failed before any baseline existed (lock or - // begin), so no truthful diff is possible: the attempt row - // exports with an empty diff and the real status. - rw.WriteHeader(http.StatusNoContent) - return - } - // The fallback succeeded, so the request is a success in every - // respect and gets the ordinary entry: re-read the stored pair - // for New (the effective include-default flag is computed from - // both rows, so request-derived text can misreport) and emit the - // real old-to-new diff. - newConfig, newErr := api.Database.GetChatSystemPromptConfig(ctx) - if newErr != nil { - // Same rule: unknown, not zero. See the in-transaction branch. - api.Logger.Warn(ctx, "audit new capture failed after fallback, writing chat system prompt without a diff", - slog.Error(newErr)) - aReq.New = aReq.Old - rw.WriteHeader(http.StatusNoContent) - return - } - if newConfig.ChatSystemPrompt == aReq.Old.SystemPrompt && - newConfig.IncludeDefaultSystemPromptSet == aReq.Old.IncludeDefaultSystemPromptSet && - newConfig.IncludeDefaultSystemPrompt == aReq.Old.IncludeDefaultSystemPrompt { - commitAudit(false) - rw.WriteHeader(http.StatusNoContent) - return - } - aReq.New.SystemPrompt = newConfig.ChatSystemPrompt - aReq.New.IncludeDefaultSystemPromptSet = newConfig.IncludeDefaultSystemPromptSet - aReq.New.IncludeDefaultSystemPrompt = newConfig.IncludeDefaultSystemPrompt + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating chat system prompt configuration.", + Detail: err.Error(), + }) + return } rw.WriteHeader(http.StatusNoContent) } @@ -4798,47 +4718,33 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ return } + // The per-setting advisory lock serializes the audit change-detection + // with the write; see putChatSystemPrompt for the rationale. var writeErr error - oldCaptured := false - // The advisory lock serializes the audit change-detection with the - // write; see putChatSystemPrompt for the rationale. err := api.Database.InTx(func(tx database.Store) error { - if err := tx.AcquireLock(ctx, database.LockIDChatSettingsWrites); err != nil { - return xerrors.Errorf("acquire chat settings write lock: %w", err) + if err := tx.AcquireLock(ctx, database.LockIDChatInstructionPlanMode); err != nil { + return xerrors.Errorf("acquire chat instruction setting write lock: %w", err) } - // Audit capture only: a failed read does not change the outcome - // of the request; the row exports with an empty diff instead. - oldInstructions, oldErr := tx.GetChatPlanModeInstructions(ctx) - if oldErr != nil { - api.Logger.Warn(ctx, "audit old capture failed, writing plan mode instructions without a diff", - slog.Error(oldErr)) - } else { - oldCaptured = true - aReq.Old.PlanModeInstructions = oldInstructions + oldInstructions, err := tx.GetChatPlanModeInstructions(ctx) + if err != nil { + return err } + aReq.Old.PlanModeInstructions = oldInstructions if err := tx.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil { + // Record the raw write error so the response matches the + // pre-transaction behavior exactly: InTx wraps callback + // errors in "execute transaction", which would otherwise leak + // into the response detail. writeErr = err return err } - if oldErr != nil { - // Without a baseline there is no meaningful change detection. - return nil - } // Re-read the stored value to build New and to compare: the write // may normalize the value, so request-derived text can - // misreport the change. Same best-effort rule as the Old - // capture. - newInstructions, newErr := tx.GetChatPlanModeInstructions(ctx) - if newErr != nil { - // A failed audit read must yield an unknown value, never a - // zero value: leaving New at its zero payload would let the - // differ record Old-to-empty, fabricating a deletion that - // did not happen. Setting New = Old renders the diff empty. - api.Logger.Warn(ctx, "audit new capture failed, writing plan mode instructions without a diff", - slog.Error(newErr)) - aReq.New = aReq.Old - return nil + // misreport the change. + newInstructions, err := tx.GetChatPlanModeInstructions(ctx) + if err != nil { + return err } if newInstructions == oldInstructions { // Value-identical PUT: cancel the entry entirely. The @@ -4850,54 +4756,19 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ return nil }, nil) if err != nil { - // Log the full InTx error first: the response below derives from - // the write error alone, so lock, commit and rollback detail - // would otherwise vanish from every observable surface. - api.Logger.Warn(ctx, "plan mode instructions update transaction failed", - slog.Error(err)) - if writeErr == nil { - // The audit machinery around the write failed (lock, begin, - // commit or rollback), not the write itself. Main's write - // path stays authoritative: the upsert is idempotent, so - // run it directly exactly as the endpoint did before the - // audit wiring existed, and derive the response from that. - // With the lock unusable, two concurrent identical writes - // can both record, which is accepted audit degradation. - writeErr = api.Database.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions) - } + // A write failure responds with the raw error exactly as the + // endpoint did before the audit wiring existed; lock, begin, + // commit and rollback failures keep the InTx wrapper so they + // stay distinguishable. + detail := err.Error() if writeErr != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error updating plan mode instructions.", - Detail: writeErr.Error(), - }) - return - } - if !oldCaptured { - // The machinery failed before any baseline existed (lock or - // begin), so no truthful diff is possible: the attempt row - // exports with an empty diff and the real status. - rw.WriteHeader(http.StatusNoContent) - return - } - // The fallback succeeded, so the request is a success in every - // respect and gets the ordinary entry: re-read the stored value - // for New (the write may normalize it, so request-derived text - // can misreport) and emit the real old-to-new diff. - newInstructions, newErr := api.Database.GetChatPlanModeInstructions(ctx) - if newErr != nil { - // Same rule: unknown, not zero. See the in-transaction branch. - api.Logger.Warn(ctx, "audit new capture failed after fallback, writing plan mode instructions without a diff", - slog.Error(newErr)) - aReq.New = aReq.Old - rw.WriteHeader(http.StatusNoContent) - return + detail = writeErr.Error() } - if newInstructions == aReq.Old.PlanModeInstructions { - commitAudit(false) - rw.WriteHeader(http.StatusNoContent) - return - } - aReq.New.PlanModeInstructions = newInstructions + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating plan mode instructions.", + Detail: detail, + }) + return } rw.WriteHeader(http.StatusNoContent) } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 726ae93a5c7..8296341e7d7 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -30,7 +30,6 @@ import ( "golang.org/x/sync/errgroup" "golang.org/x/xerrors" - "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" agplaibridge "github.com/coder/coder/v2/aibridge" "github.com/coder/coder/v2/coderd" @@ -308,41 +307,6 @@ func (s *failNextUpsertChatSystemPromptStore) UpsertChatSystemPrompt(ctx context return s.Store.UpsertChatSystemPrompt(ctx, prompt) } -// failNextGetChatPlanModeInstructionsStore lets a test force plan-mode -// instructions reads to fail until the armed count reaches zero. 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. -type failNextGetChatPlanModeInstructionsStore struct { - database.Store - - armedGetChatPlanModeInstructionsFailures *atomic.Int64 -} - -func newFailNextGetChatPlanModeInstructionsStore(store database.Store) *failNextGetChatPlanModeInstructionsStore { - return &failNextGetChatPlanModeInstructionsStore{ - Store: store, - armedGetChatPlanModeInstructionsFailures: &atomic.Int64{}, - } -} - -func (s *failNextGetChatPlanModeInstructionsStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { - return s.Store.InTx(func(tx database.Store) error { - return function(&failNextGetChatPlanModeInstructionsStore{ - Store: tx, - armedGetChatPlanModeInstructionsFailures: s.armedGetChatPlanModeInstructionsFailures, - }) - }, txOpts) -} - -func (s *failNextGetChatPlanModeInstructionsStore) GetChatPlanModeInstructions(ctx context.Context) (string, error) { - if s.armedGetChatPlanModeInstructionsFailures != nil && s.armedGetChatPlanModeInstructionsFailures.Load() > 0 { - s.armedGetChatPlanModeInstructionsFailures.Add(-1) - return "", stderrors.New("forced plan mode instructions read failure") - } - return s.Store.GetChatPlanModeInstructions(ctx) -} - // failNextUpsertChatPlanModeInstructionsStore lets a test force the plan-mode // instructions upsert to fail once, sharing its failure state across InTx // wrappers. @@ -375,77 +339,6 @@ func (s *failNextUpsertChatPlanModeInstructionsStore) UpsertChatPlanModeInstruct return s.Store.UpsertChatPlanModeInstructions(ctx, instructions) } -// failNextChatSettingsLockStore lets a test force the chat settings advisory -// lock acquisition to fail once, sharing its failure state across InTx -// wrappers. -type failNextChatSettingsLockStore struct { - database.Store - - failNextAcquireLock *atomic.Bool -} - -func newFailNextChatSettingsLockStore(store database.Store) *failNextChatSettingsLockStore { - return &failNextChatSettingsLockStore{ - Store: store, - failNextAcquireLock: &atomic.Bool{}, - } -} - -func (s *failNextChatSettingsLockStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { - return s.Store.InTx(func(tx database.Store) error { - return function(&failNextChatSettingsLockStore{ - Store: tx, - failNextAcquireLock: s.failNextAcquireLock, - }) - }, txOpts) -} - -func (s *failNextChatSettingsLockStore) AcquireLock(ctx context.Context, id int64) error { - if s.failNextAcquireLock.CompareAndSwap(true, false) { - return stderrors.New("forced advisory lock acquisition failure") - } - return s.Store.AcquireLock(ctx, id) -} - -// commitFailChatPlanModeInstructionsStore runs the audited callback -// normally, then fails the transaction exactly like a commit failure: Old -// was captured, the upsert succeeded, and only the machinery completing the -// transaction failed. The fallback re-read runs outside the transaction and -// succeeds. -type commitFailChatPlanModeInstructionsStore struct { - database.Store - - // failNextCommit arms exactly one commit failure for the next audited - // transaction whose callback succeeds. - failNextCommit *atomic.Bool - // inFallback marks the direct write path so it is never failed. - inFallback *atomic.Bool -} - -func newCommitFailChatPlanModeInstructionsStore(store database.Store) *commitFailChatPlanModeInstructionsStore { - return &commitFailChatPlanModeInstructionsStore{ - Store: store, - failNextCommit: &atomic.Bool{}, - inFallback: &atomic.Bool{}, - } -} - -func (s *commitFailChatPlanModeInstructionsStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { - // The fallback direct write calls the plain upsert, not InTx, so only - // the audited transaction reaches this wrapper. - return s.Store.InTx(func(tx database.Store) error { - if err := function(tx); err != nil { - return err - } - // The callback succeeded; fail completion once when armed, like a - // commit error. - if s.failNextCommit.CompareAndSwap(true, false) { - return stderrors.New("commit transaction: forced commit failure") - } - return nil - }, txOpts) -} - // normalizingChatPlanModeInstructionsStore stores an uppercased value on// normalizingChatPlanModeInstructionsStore stores an uppercased value on // every upsert, so tests can prove the audited New comes from the stored // value, not the request text. @@ -463,31 +356,6 @@ func (s *normalizingChatPlanModeInstructionsStore) UpsertChatPlanModeInstruction return s.Store.UpsertChatPlanModeInstructions(ctx, strings.ToUpper(instructions)) } -// recordingSink captures slog entries so tests can assert on messages the -// handler logs, e.g. best-effort audit capture failures. -type recordingSink struct { - mu sync.Mutex - entries []slog.SinkEntry -} - -func (s *recordingSink) LogEntry(_ context.Context, e slog.SinkEntry) { - s.mu.Lock() - defer s.mu.Unlock() - s.entries = append(s.entries, e) -} - -func (*recordingSink) Sync() {} - -func (s *recordingSink) messages() []string { - s.mu.Lock() - defer s.mu.Unlock() - msgs := make([]string, len(s.entries)) - for i, e := range s.entries { - msgs[i] = e.Message - } - return msgs -} - // failNextUpdateChatModelConfigStore shares its failure state across InTx // wrappers so tests can force a specific in-transaction model-config update to // return sql.ErrNoRows. @@ -13149,105 +13017,6 @@ If a workspace is needed, use list_templates before create_workspace and follow require.NotContains(t, string(logs[0].Diff), "must not leak") }) - // A failing audit capture read must not change the request outcome: - // the write still happens and the response is still 204, the entry is - // just skipped. The Old-capture read fails here. - t.Run("AuditOldCaptureFailureStillWrites", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - rawDB, pubsub := dbtestutil.NewDB(t) - store := newFailNextChatSystemPromptStore(rawDB) - mAudit := audit.NewMock() - sink := &recordingSink{} - logger := slog.Make(sink) - rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Database: store, - Pubsub: pubsub, - DeploymentValues: coderdtest.DeploymentValues(t), - Auditor: mAudit, - Logger: &logger, - }) - 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.armedGetChatSystemPromptConfigFailures.Store(1) - err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ - SystemPrompt: "Written despite the failed audit read.", - IncludeDefaultSystemPrompt: ptr.Ref(true), - }) - require.NoError(t, err) - logs := mAudit.AuditLogs() - require.Len(t, logs, 1) - require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) - require.JSONEq(t, "{}", string(logs[0].Diff)) - require.Contains(t, sink.messages(), "audit old capture failed, writing chat system prompt without a diff") - - resp, err := client.GetChatSystemPrompt(ctx) - require.NoError(t, err) - require.Equal(t, "Written despite the failed audit read.", resp.SystemPrompt) - require.True(t, resp.IncludeDefaultSystemPrompt) - }) - - // Isolated New-branch proof: the Old capture succeeds, the upserts - // succeed, and only the New re-read fails. The write still completes - // with 204 and the warn names the New capture. The write-scoped store - // counts GetChatSystemPromptConfig calls across InTx boundaries: the - // first failing call is the New re-read because the Old capture is the - // first call and only reads, while the upsert failure injection used - // in earlier tests aborts before any re-read. - t.Run("AuditNewCaptureDegradesViaWarn", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - rawDB, pubsub := dbtestutil.NewDB(t) - store := newFailNextChatSystemPromptStore(rawDB) - mAudit := audit.NewMock() - sink := &recordingSink{} - logger := slog.Make(sink) - rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Database: store, - Pubsub: pubsub, - DeploymentValues: coderdtest.DeploymentValues(t), - Auditor: mAudit, - Logger: &logger, - }) - 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.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ - SystemPrompt: "Initial prompt.", - IncludeDefaultSystemPrompt: ptr.Ref(true), - }) - require.NoError(t, err) - require.Len(t, mAudit.AuditLogs(), 1) - - // The Old capture of the next PUT reads the stored "Initial - // prompt." and succeeds; the armed failure then fires on the New - // re-read only, because the Old read is the first call and the - // armed countdown starts after it. - mAudit.ResetLogs() - store.armedGetChatSystemPromptConfigFailures.Store(1) - store.getChatSystemPromptConfigCallsBeforeFailure.Store(1) - err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ - SystemPrompt: "Changed prompt.", - }) - require.NoError(t, err) - logs := mAudit.AuditLogs() - require.Len(t, logs, 1) - require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) - require.JSONEq(t, "{}", string(logs[0].Diff)) - require.Contains(t, sink.messages(), "audit new capture failed, writing chat system prompt without a diff") - - resp, err := client.GetChatSystemPrompt(ctx) - require.NoError(t, err) - require.Equal(t, "Changed prompt.", resp.SystemPrompt) - }) - // 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 @@ -13308,49 +13077,6 @@ If a workspace is needed, use list_templates before create_workspace and follow require.Empty(t, mAudit.AuditLogs()) }) - // When the advisory lock cannot be taken, the write still happens - // through main's direct path and the response stays 204; the attempt - // exports with an empty diff. Accepted degradation: two concurrent - // identical writes can both record in this state. - t.Run("AuditLockFailureFallsBack", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - rawDB, pubsub := dbtestutil.NewDB(t) - store := newFailNextChatSettingsLockStore(rawDB) - mAudit := audit.NewMock() - sink := &recordingSink{} - logger := slog.Make(sink) - rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Database: store, - Pubsub: pubsub, - DeploymentValues: coderdtest.DeploymentValues(t), - Auditor: mAudit, - Logger: &logger, - }) - 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: "Written despite the lock failure.", - IncludeDefaultSystemPrompt: ptr.Ref(true), - }) - require.NoError(t, err) - logs := mAudit.AuditLogs() - require.Len(t, logs, 1) - require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) - require.JSONEq(t, "{}", string(logs[0].Diff)) - require.Contains(t, sink.messages(), "chat system prompt update transaction failed") - - resp, err := client.GetChatSystemPrompt(ctx) - require.NoError(t, err) - require.Equal(t, "Written despite the lock failure.", resp.SystemPrompt) - require.True(t, resp.IncludeDefaultSystemPrompt) - }) - // 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. @@ -13673,42 +13399,6 @@ func TestChatPlanModeInstructions(t *testing.T) { require.NotContains(t, string(logs[0].Diff), "must not leak") }) - // A failing audit old-capture read must not change the request - // outcome: the write still happens and the response is still 204, - // the entry is just skipped. - t.Run("AuditOldCaptureFailureStillWrites", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - rawDB, pubsub := dbtestutil.NewDB(t) - store := newFailNextGetChatPlanModeInstructionsStore(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.armedGetChatPlanModeInstructionsFailures.Store(1) - err := client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ - PlanModeInstructions: "Written despite the failed audit read.", - }) - require.NoError(t, err) - logs := mAudit.AuditLogs() - require.Len(t, logs, 1) - require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) - require.JSONEq(t, "{}", string(logs[0].Diff)) - - resp, err := client.GetChatPlanModeInstructions(ctx) - require.NoError(t, err) - require.Equal(t, "Written despite the failed audit read.", resp.PlanModeInstructions) - }) - // The audited New must be the STORED value, not the request text: the // write may normalize the value, and request-derived text would // misreport the change. The store uppercases every upsert; the entry @@ -13811,186 +13501,6 @@ func TestChatPlanModeInstructions(t *testing.T) { require.Empty(t, mAudit.AuditLogs()) }) - // When the advisory lock cannot be taken, the write still happens - // through main's direct path and the response stays 204; the attempt - // exports with an empty diff. - t.Run("AuditLockFailureFallsBack", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - rawDB, pubsub := dbtestutil.NewDB(t) - store := newFailNextChatSettingsLockStore(rawDB) - mAudit := audit.NewMock() - sink := &recordingSink{} - logger := slog.Make(sink) - rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Database: store, - Pubsub: pubsub, - DeploymentValues: coderdtest.DeploymentValues(t), - Auditor: mAudit, - Logger: &logger, - }) - 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 the lock failure.", - }) - require.NoError(t, err) - logs := mAudit.AuditLogs() - require.Len(t, logs, 1) - require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) - require.JSONEq(t, "{}", string(logs[0].Diff)) - require.Contains(t, sink.messages(), "plan mode instructions update transaction failed") - - resp, err := client.GetChatPlanModeInstructions(ctx) - require.NoError(t, err) - require.Equal(t, "Written despite the lock failure.", resp.PlanModeInstructions) - }) - - // When the transaction fails AFTER Old was captured and the write - // succeeded (a commit-time machinery failure), the fallback direct - // write lands the value and the request succeeds, so the entry is - // the ordinary one: Old to the stored New, real diff, 204. A - // degraded empty-diff row here would under-report a change that - // happened. - t.Run("AuditCommitFailureEmitsOrdinaryEntry", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - rawDB, pubsub := dbtestutil.NewDB(t) - store := newCommitFailChatPlanModeInstructionsStore(rawDB) - 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) - if oldSettings.PlanModeInstructions == newSettings.PlanModeInstructions { - return audit.Map{} - } - return audit.Map{ - "plan_mode_instructions": {Old: oldSettings.PlanModeInstructions, New: newSettings.PlanModeInstructions}, - } - }) - sink := &recordingSink{} - logger := slog.Make(sink) - rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Database: store, - Pubsub: pubsub, - DeploymentValues: coderdtest.DeploymentValues(t), - Auditor: mAudit, - Logger: &logger, - }) - 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: "Baseline instructions.", - }) - require.NoError(t, err) - require.Len(t, mAudit.AuditLogs(), 1) - - mAudit.ResetLogs() - store.failNextCommit.Store(true) - err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ - PlanModeInstructions: "Committed instructions.", - }) - require.NoError(t, err) - logs := mAudit.AuditLogs() - require.Len(t, logs, 1) - require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) - require.Contains(t, sink.messages(), "plan mode instructions update transaction failed") - var diff map[string]codersdk.AuditDiffField - require.NoError(t, json.Unmarshal(logs[0].Diff, &diff)) - require.Equal(t, map[string]codersdk.AuditDiffField{ - "plan_mode_instructions": {Old: "Baseline instructions.", New: "Committed instructions."}, - }, diff) - - resp, err := client.GetChatPlanModeInstructions(ctx) - require.NoError(t, err) - require.Equal(t, "Committed instructions.", resp.PlanModeInstructions) - }) - - // A retry with a divergent value after a lock failure lands through - // the direct write path: the request succeeds, and because the lock - // failure meant no baseline existed, the row is the degraded one - // (real status, empty diff). A subsequent ordinary write with no - // failures then produces the ordinary old-to-new entry, proving the - // degraded write did not desync the baseline. - t.Run("AuditFallbackThenOrdinaryEntry", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - rawDB, pubsub := dbtestutil.NewDB(t) - store := newFailNextChatSettingsLockStore(rawDB) - 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) - if oldSettings.PlanModeInstructions == newSettings.PlanModeInstructions { - return audit.Map{} - } - return audit.Map{ - "plan_mode_instructions": {Old: oldSettings.PlanModeInstructions, New: newSettings.PlanModeInstructions}, - } - }) - 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: "Baseline instructions.", - }) - require.NoError(t, err) - require.Len(t, mAudit.AuditLogs(), 1) - - // Lock failure on the retry with a corrected value: no baseline - // was captured, so the row is degraded (empty diff) and the - // direct write path still lands the value. - mAudit.ResetLogs() - store.failNextAcquireLock.Store(true) - err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ - PlanModeInstructions: "Corrected instructions.", - }) - require.NoError(t, err) - logs := mAudit.AuditLogs() - require.Len(t, logs, 1) - require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) - require.JSONEq(t, "{}", string(logs[0].Diff)) - - resp, err := client.GetChatPlanModeInstructions(ctx) - require.NoError(t, err) - require.Equal(t, "Corrected instructions.", resp.PlanModeInstructions) - - // The next ordinary write diffs against the fallback-written - // value, proving the degraded write did not desync the baseline. - mAudit.ResetLogs() - err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ - PlanModeInstructions: "Third instructions.", - }) - 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{ - "plan_mode_instructions": {Old: "Corrected instructions.", New: "Third instructions."}, - }, diff) - }) - // 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. diff --git a/enterprise/coderd/chatinstructions_audit_test.go b/enterprise/coderd/chatinstructions_audit_test.go index b7b7dac21ef..4c5c0244335 100644 --- a/enterprise/coderd/chatinstructions_audit_test.go +++ b/enterprise/coderd/chatinstructions_audit_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" stderrors "errors" + "net/http" "sync" "sync/atomic" "testing" @@ -125,23 +126,26 @@ func TestChatInstructionSettingsDegradedNewCapture(t *testing.T) { require.Len(t, backend.entries(), 1) // Fail the New re-read only: the Old capture succeeds, the write - // succeeds, and the re-read fails. The row must not claim the prompt - // was cleared. + // succeeds, and the re-read fails. Because the read is fatal on + // measured unreachability (a statement error aborts the transaction), + // the request fails and rolls back, so no row can carry a fabricated + // Old-to-empty deletion. store.fail.Store(true) err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ SystemPrompt: "Changed prompt.", }) - require.NoError(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode()) + // The failed request records the attempt with an empty diff; no + // fabricated deletion can appear. require.Len(t, backend.entries(), 2) - var diff map[string]codersdk.AuditDiffField - require.NoError(t, json.Unmarshal(backend.entries()[1].Diff, &diff)) - // The unknown-value rule: the diff is empty, never a fabricated - // Old-to-empty deletion. - require.Empty(t, diff) + require.JSONEq(t, "{}", string(backend.entries()[1].Diff)) + // The write rolled back. resp, err := client.GetChatSystemPrompt(ctx) require.NoError(t, err) - require.Equal(t, "Changed prompt.", resp.SystemPrompt) + require.Equal(t, "Baseline prompt.", resp.SystemPrompt) } // TestChatInstructionSettingsIncludeDefaultPresence exercises the production From 9617fb7818bd0fdb46d8ff15ee33bf3611e599b0 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 30 Jul 2026 15:22:21 +0000 Subject: [PATCH 10/16] test: prove chat instruction lock IDs cannot collide GenLockID derives the per-setting advisory lock IDs from a string, so collision-freedom is a property to verify, not argue. The test computes both generated IDs, asserts they are pairwise distinct, and asserts each is distinct from every sequential LockID* constant, listed explicitly because iota constants cannot be enumerated programmatically. A future constant that collides now fails in review rather than in a production deadlock. --- coderd/database/lock_internal_test.go | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 coderd/database/lock_internal_test.go diff --git a/coderd/database/lock_internal_test.go b/coderd/database/lock_internal_test.go new file mode 100644 index 00000000000..c3208414615 --- /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) + } + } +} From 41c9c085a36c0d3ec592e3ef7c834b02b626b60e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 30 Jul 2026 17:38:19 +0000 Subject: [PATCH 11/16] fix: derive the audit New from the write, never re-read it The previous premise, that no read failure can leave the write able to succeed, was falsified on real PostgreSQL: a database.Store wrapper can error before any SQL reaches Postgres, and query-local context cancellation fails the individual query without marking the server-side transaction failed (InTx begins it with context.Background). The post-write New read was the reachable case: on main the write lands and the client just never sees the response, while the audited handler rolled it back, silently discarding a change an administrator made. That is worse than any audit imprecision. The post-write read is deleted rather than protected. New is derived from what was written: the upserts store $1 verbatim, so the stored value is the request value, and the effective include-default flag follows GetChatSystemPromptConfig's rule from the written toggle row and the written prompt. A parity test compares the derivation against the getter's result on the happy path for every combination of written prompt and include-default input, so drift is caught. The Old capture moves back inside the per-setting lock but stays best effort: it runs before the write, so abandoning the transaction on its error costs nothing, and the handler runs the direct write path with no entry and a warning. The no-op decision is staged into a local and applied only after the transaction commits, so a commit failure cannot suppress an attempt row. The per-setting lock stays, now the only audit-added statement in the transaction, with a service-owned 5s bound on the wait so a waiter cannot hang past the client deadline. Plan-mode's audit-added transaction machinery (lock, begin, commit, rollback, and the Old read) runs the direct write instead, matching main's non-transactional behavior, while the system-prompt endpoint, already transactional on main, surfaces those errors exactly as main's. The full InTx error is logged before the response detail is chosen, so a rollback failure layered on a write failure is never absent from response, audit row and logs simultaneously. The concurrency tests are strengthened to the barrier shape: with the lock, two concurrent identical PUTs produce one entry; with the lock removed and both transactions parked at the Old capture, both read the same stale Old and both emit, the deterministic mutation proof. --- coderd/exp_chats.go | 214 ++++++++++---- coderd/exp_chats_test.go | 271 +++++++++++++----- .../coderd/chatinstructions_audit_test.go | 75 ++--- 3 files changed, 386 insertions(+), 174 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 06fbc131f60..bf83a5202eb 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4546,6 +4546,13 @@ 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 fails with 500 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() @@ -4589,23 +4596,37 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { return } + var ( + noChange bool + oldCaptured bool + oldReadErr 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 cancels its duplicate audit entry. Only writers of the - // same setting contend. + // 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(ctx, database.LockIDChatInstructionSystemPrompt); err != nil { + if err := tx.AcquireLock(lockCtx, database.LockIDChatInstructionSystemPrompt); err != nil { return xerrors.Errorf("acquire chat instruction setting write lock: %w", err) } - oldConfig, err := tx.GetChatSystemPromptConfig(ctx) - if err != nil { - return 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 } @@ -4619,34 +4640,72 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { return err } } - // Re-read the pair to build New: the effective include-default flag - // is computed from the toggle row AND the current prompt, so a - // prompt-only write can flip it without the request carrying the - // flag. - newConfig, err := tx.GetChatSystemPromptConfig(ctx) - if err != nil { - return err - } - if newConfig.ChatSystemPrompt == oldConfig.ChatSystemPrompt && - newConfig.IncludeDefaultSystemPromptSet == oldConfig.IncludeDefaultSystemPromptSet && - newConfig.IncludeDefaultSystemPrompt == oldConfig.IncludeDefaultSystemPrompt { - // Value-identical PUT: cancel the entry entirely. The - // upserts above still run either way. - commitAudit(false) - return nil - } - aReq.New.SystemPrompt = newConfig.ChatSystemPrompt - aReq.New.IncludeDefaultSystemPromptSet = newConfig.IncludeDefaultSystemPromptSet - aReq.New.IncludeDefaultSystemPrompt = newConfig.IncludeDefaultSystemPrompt + + // 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 { + if oldReadErr != nil { + // The 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 old capture failed, writing chat system prompt without an audit entry", + slog.Error(oldReadErr)) + 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, lock, write, + // commit and rollback errors all surface exactly as main's. 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) } @@ -4718,57 +4777,90 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ return } - // The per-setting advisory lock serializes the audit change-detection - // with the write; see putChatSystemPrompt for the rationale. + // 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(ctx, database.LockIDChatInstructionPlanMode); err != nil { + if err := tx.AcquireLock(lockCtx, database.LockIDChatInstructionPlanMode); err != nil { return xerrors.Errorf("acquire chat instruction setting write lock: %w", err) } - oldInstructions, err := tx.GetChatPlanModeInstructions(ctx) - if err != nil { - return 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 { - // Record the raw write error so the response matches the - // pre-transaction behavior exactly: InTx wraps callback - // errors in "execute transaction", which would otherwise leak - // into the response detail. writeErr = err return err } - // Re-read the stored value to build New and to compare: the write - // may normalize the value, so request-derived text can - // misreport the change. - newInstructions, err := tx.GetChatPlanModeInstructions(ctx) - if err != nil { - return err - } - if newInstructions == oldInstructions { - // Value-identical PUT: cancel the entry entirely. The - // upsert above still runs either way. - commitAudit(false) - return nil - } - aReq.New.PlanModeInstructions = newInstructions + aReq.New.PlanModeInstructions = sanitizedInstructions + noChange = aReq.New.PlanModeInstructions == aReq.Old.PlanModeInstructions return nil }, nil) if err != nil { - // A write failure responds with the raw error exactly as the - // endpoint did before the audit wiring existed; lock, begin, - // commit and rollback failures keep the InTx wrapper so they - // stay distinguishable. - detail := err.Error() - if writeErr != nil { - detail = writeErr.Error() + // 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 } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error updating plan mode instructions.", - Detail: detail, - }) - 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 oldReadErr != nil { + // The write landed through the direct path with no baseline, + // so no entry: warn and finish with the success response. + api.Logger.Warn(ctx, "audit old capture failed, writing plan mode instructions without an audit entry", + slog.Error(oldReadErr)) + 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 8296341e7d7..1209b59e20d 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -339,21 +339,65 @@ func (s *failNextUpsertChatPlanModeInstructionsStore) UpsertChatPlanModeInstruct return s.Store.UpsertChatPlanModeInstructions(ctx, instructions) } -// normalizingChatPlanModeInstructionsStore stores an uppercased value on// normalizingChatPlanModeInstructionsStore stores an uppercased value on -// every upsert, so tests can prove the audited New comes from the stored -// value, not the request text. -type normalizingChatPlanModeInstructionsStore struct { +// lockSwitchChatPlanModeInstructionsStore can disable the per-setting +// advisory lock (to prove its effect) and stall the Old-capture read until +// every concurrent transaction is parked there, 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 (s *normalizingChatPlanModeInstructionsStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { +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(&normalizingChatPlanModeInstructionsStore{Store: tx}) + return function(&lockSwitchChatPlanModeInstructionsStore{ + Store: tx, + lockEnabled: s.lockEnabled, + mu: s.mu, + parked: s.parked, + target: s.target, + release: s.release, + }) }, txOpts) } -func (s *normalizingChatPlanModeInstructionsStore) UpsertChatPlanModeInstructions(ctx context.Context, instructions string) error { - return s.Store.UpsertChatPlanModeInstructions(ctx, strings.ToUpper(instructions)) +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) { + 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 s.Store.GetChatPlanModeInstructions(ctx) } // failNextUpdateChatModelConfigStore shares its failure state across InTx @@ -13077,9 +13121,71 @@ If a workspace is needed, use list_templates before create_workspace and follow require.Empty(t, mAudit.AuditLogs()) }) + // 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) @@ -13091,6 +13197,14 @@ If a workspace is needed, use list_templates before create_workspace and follow // 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) @@ -13399,54 +13513,6 @@ func TestChatPlanModeInstructions(t *testing.T) { require.NotContains(t, string(logs[0].Diff), "must not leak") }) - // The audited New must be the STORED value, not the request text: the - // write may normalize the value, and request-derived text would - // misreport the change. The store uppercases every upsert; the entry - // must carry the stored uppercase form. - t.Run("AuditNewIsStoredValue", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - rawDB, pubsub := dbtestutil.NewDB(t) - store := &normalizingChatPlanModeInstructionsStore{Store: rawDB} - 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{ - "plan_mode_instructions": {Old: oldSettings.PlanModeInstructions, New: newSettings.PlanModeInstructions}, - } - }) - 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: "normalize me", - }) - 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{ - "plan_mode_instructions": {Old: "", New: "NORMALIZE ME"}, - }, diff) - - resp, err := client.GetChatPlanModeInstructions(ctx) - require.NoError(t, err) - require.Equal(t, "NORMALIZE ME", resp.PlanModeInstructions) - }) - // 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 @@ -13501,20 +13567,41 @@ func TestChatPlanModeInstructions(t *testing.T) { require.Empty(t, mAudit.AuditLogs()) }) - // 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 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() - auditClient := newChatClient(t, func(opts *coderdtest.Options) { - opts.Auditor = mAudit + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + Auditor: mAudit, }) - _ = coderdtest.CreateFirstUser(t, auditClient.Client) + 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) @@ -13524,7 +13611,7 @@ func TestChatPlanModeInstructions(t *testing.T) { go func() { defer wg.Done() <-start - errs <- auditClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ + errs <- client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{ PlanModeInstructions: "Concurrent instructions.", }) }() @@ -13535,10 +13622,62 @@ func TestChatPlanModeInstructions(t *testing.T) { for err := range errs { require.NoError(t, err) } + require.Len(t, mAudit.AuditLogs(), 1) + }) - logs := mAudit.AuditLogs() - require.Len(t, logs, 1) - require.Equal(t, database.AuditActionWrite, logs[0].Action) + // 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) }) } diff --git a/enterprise/coderd/chatinstructions_audit_test.go b/enterprise/coderd/chatinstructions_audit_test.go index 4c5c0244335..e6d810f0e05 100644 --- a/enterprise/coderd/chatinstructions_audit_test.go +++ b/enterprise/coderd/chatinstructions_audit_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" stderrors "errors" - "net/http" "sync" "sync/atomic" "testing" @@ -53,56 +52,50 @@ func (b *captureBackend) entries() []database.AuditLog { return out } -// failNextChatSystemPromptConfigStore fails the New re-read of the chat -// system prompt configuration once: the first read inside the audited -// transaction (the Old capture) and every later read succeed, the second -// fails. Failure state is shared across InTx wrappers. -type failNextChatSystemPromptConfigStore struct { +// 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 - calls *atomic.Int64 - fail *atomic.Bool + fail *atomic.Bool } -func newFailNextChatSystemPromptConfigStore(store database.Store) *failNextChatSystemPromptConfigStore { - return &failNextChatSystemPromptConfigStore{ +func newFailNextGetChatSystemPromptConfigStore(store database.Store) *failNextGetChatSystemPromptConfigStore { + return &failNextGetChatSystemPromptConfigStore{ Store: store, - calls: &atomic.Int64{}, fail: &atomic.Bool{}, } } -func (s *failNextChatSystemPromptConfigStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { +func (s *failNextGetChatSystemPromptConfigStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { return s.Store.InTx(func(tx database.Store) error { - s.calls.Store(0) - return function(&failNextChatSystemPromptConfigStore{ + return function(&failNextGetChatSystemPromptConfigStore{ Store: tx, - calls: s.calls, fail: s.fail, }) }, txOpts) } -func (s *failNextChatSystemPromptConfigStore) GetChatSystemPromptConfig(ctx context.Context) (database.GetChatSystemPromptConfigRow, error) { - // The second read inside the audited transaction is the New re-read. - if s.calls.Add(1) == 2 && s.fail.CompareAndSwap(true, false) { - return database.GetChatSystemPromptConfigRow{}, stderrors.New("forced new capture failure") +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) } -// TestChatInstructionSettingsDegradedNewCapture exercises the production -// enterprise differ against the chat instruction settings handlers, which the -// empty-diff mock auditor cannot do: a degraded New capture must yield an -// unknown value, never a zero value, so the diff is empty rather than a -// fabricated deletion. -func TestChatInstructionSettingsDegradedNewCapture(t *testing.T) { +// 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 := newFailNextChatSystemPromptConfigStore(rawDB) + store := newFailNextGetChatSystemPromptConfigStore(rawDB) backend := &captureBackend{} auditor := entaudit.NewAuditor(rawDB, entaudit.DefaultFilter, backend) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) @@ -118,34 +111,22 @@ func TestChatInstructionSettingsDegradedNewCapture(t *testing.T) { // 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: "Baseline prompt.", + SystemPrompt: "Changed prompt.", IncludeDefaultSystemPrompt: ptr.Ref(true), }) require.NoError(t, err) - require.Len(t, backend.entries(), 1) + require.Empty(t, backend.entries()) - // Fail the New re-read only: the Old capture succeeds, the write - // succeeds, and the re-read fails. Because the read is fatal on - // measured unreachability (a statement error aborts the transaction), - // the request fails and rolls back, so no row can carry a fabricated - // Old-to-empty deletion. - store.fail.Store(true) - err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ - SystemPrompt: "Changed prompt.", - }) - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode()) - // The failed request records the attempt with an empty diff; no - // fabricated deletion can appear. - require.Len(t, backend.entries(), 2) - require.JSONEq(t, "{}", string(backend.entries()[1].Diff)) - - // The write rolled back. resp, err := client.GetChatSystemPrompt(ctx) require.NoError(t, err) - require.Equal(t, "Baseline prompt.", resp.SystemPrompt) + require.Equal(t, "Changed prompt.", resp.SystemPrompt) + require.True(t, resp.IncludeDefaultSystemPrompt) } // TestChatInstructionSettingsIncludeDefaultPresence exercises the production From 34f7ffbce483cc21e6ce4f18f83a04535dc2a07f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:54:49 +0000 Subject: [PATCH 12/16] test(coderd): deflake TestChatPlanModeInstructions barrier The without-lock concurrency subtest parked goroutines before the Old-capture read. The goroutine that released the barrier could write and commit before a slowly waking waiter executed its read, which then saw the committed value under read committed, detected no change, and suppressed its audit entry, failing the expected-duplicate assertion. Read before parking so every waiter deterministically holds the same stale value. --- coderd/exp_chats_test.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 1209b59e20d..35e32e89a7c 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -340,10 +340,11 @@ func (s *failNextUpsertChatPlanModeInstructionsStore) UpsertChatPlanModeInstruct } // lockSwitchChatPlanModeInstructionsStore can disable the per-setting -// advisory lock (to prove its effect) and stall the Old-capture read until -// every concurrent transaction is parked there, 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. +// 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 @@ -386,6 +387,12 @@ func (s *lockSwitchChatPlanModeInstructionsStore) AcquireLock(ctx context.Contex } 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++ @@ -397,7 +404,7 @@ func (s *lockSwitchChatPlanModeInstructionsStore) GetChatPlanModeInstructions(ct <-s.release } } - return s.Store.GetChatPlanModeInstructions(ctx) + return instructions, err } // failNextUpdateChatModelConfigStore shares its failure state across InTx From 5ff5e3e1c05992b40b77934410008b0665393674 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:23:59 +0000 Subject: [PATCH 13/16] fix(coderd/database): renumber audit migration to 000571 Main gained 000562_oauth2_public_client_tokens, so the PR merge checkout contained two migrations with version 000562 and every database-touching CI job failed with a duplicate migration file error. No fixture or test references the number. --- ...s.down.sql => 000571_audit_chat_instruction_settings.down.sql} | 0 ...tings.up.sql => 000571_audit_chat_instruction_settings.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000562_audit_chat_instruction_settings.down.sql => 000571_audit_chat_instruction_settings.down.sql} (100%) rename coderd/database/migrations/{000562_audit_chat_instruction_settings.up.sql => 000571_audit_chat_instruction_settings.up.sql} (100%) diff --git a/coderd/database/migrations/000562_audit_chat_instruction_settings.down.sql b/coderd/database/migrations/000571_audit_chat_instruction_settings.down.sql similarity index 100% rename from coderd/database/migrations/000562_audit_chat_instruction_settings.down.sql rename to coderd/database/migrations/000571_audit_chat_instruction_settings.down.sql diff --git a/coderd/database/migrations/000562_audit_chat_instruction_settings.up.sql b/coderd/database/migrations/000571_audit_chat_instruction_settings.up.sql similarity index 100% rename from coderd/database/migrations/000562_audit_chat_instruction_settings.up.sql rename to coderd/database/migrations/000571_audit_chat_instruction_settings.up.sql From ed1a753b57b3353f8038913a3247656bd9d9d49d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:06:37 +0000 Subject: [PATCH 14/16] fix(coderd): address Codex review feedback on audit lock and filter coverage - Fall back to the unaudited write path when the chat system prompt advisory lock times out, instead of failing the update with 500. The lock exists only for audit change detection, so contention must not block a write that succeeded before the audit wiring existed. - Add Storybook interaction coverage that opens the audit resource-type filter and selects the Chat Instruction Settings option, verifying the generated resource type and its friendly label together. --- coderd/exp_chats.go | 29 ++++--- coderd/exp_chats_test.go | 81 +++++++++++++++++++ .../pages/AuditPage/AuditPageView.stories.tsx | 50 +++++++++++- 3 files changed, 149 insertions(+), 11 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index bf83a5202eb..cd3abddef97 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4549,8 +4549,8 @@ 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 fails with 500 instead of hanging until the client -// deadline. +// 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) { @@ -4600,6 +4600,7 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { 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, @@ -4610,6 +4611,7 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { 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) } @@ -4668,12 +4670,17 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { return nil }, nil) if err != nil { - if oldReadErr != nil { - // The 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 old capture failed, writing chat system prompt without an audit entry", - slog.Error(oldReadErr)) + 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 { @@ -4693,8 +4700,10 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) return } - // This endpoint was transactional on main, so begin, lock, write, - // commit and rollback errors all surface exactly as main's. + // 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(), diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 35e32e89a7c..ca0be73757e 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -307,6 +307,42 @@ func (s *failNextUpsertChatSystemPromptStore) UpsertChatSystemPrompt(ctx context 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. @@ -13128,6 +13164,51 @@ If a workspace is needed, use list_templates before create_workspace and follow 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 diff --git a/site/src/pages/AuditPage/AuditPageView.stories.tsx b/site/src/pages/AuditPage/AuditPageView.stories.tsx index 035dbe5fca5..d9d94536cb0 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: { From 20eabdb5d909aa19c850ca22c028702f9b004e29 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:18:27 +0000 Subject: [PATCH 15/16] fix(coderd/database): renumber audit migration to 000573 Main gained 000571_pool_aware_chat_acquisition_index and 000572_chat_files_token_crypto_key_feature while this PR aged, so the branch migration moves past both to keep versions unique and ordered. --- ...s.down.sql => 000573_audit_chat_instruction_settings.down.sql} | 0 ...tings.up.sql => 000573_audit_chat_instruction_settings.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000571_audit_chat_instruction_settings.down.sql => 000573_audit_chat_instruction_settings.down.sql} (100%) rename coderd/database/migrations/{000571_audit_chat_instruction_settings.up.sql => 000573_audit_chat_instruction_settings.up.sql} (100%) diff --git a/coderd/database/migrations/000571_audit_chat_instruction_settings.down.sql b/coderd/database/migrations/000573_audit_chat_instruction_settings.down.sql similarity index 100% rename from coderd/database/migrations/000571_audit_chat_instruction_settings.down.sql rename to coderd/database/migrations/000573_audit_chat_instruction_settings.down.sql diff --git a/coderd/database/migrations/000571_audit_chat_instruction_settings.up.sql b/coderd/database/migrations/000573_audit_chat_instruction_settings.up.sql similarity index 100% rename from coderd/database/migrations/000571_audit_chat_instruction_settings.up.sql rename to coderd/database/migrations/000573_audit_chat_instruction_settings.up.sql From c150c76cfe11e880b716de532346fbf80411b4ba Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:28:51 +0000 Subject: [PATCH 16/16] fix(coderd): suppress plan-mode audit entry when lock failure skips baseline A lock-wait timeout in putChatPlanModeInstructions fell back to the direct write but never canceled the deferred audit, emitting a successful 204 entry with an empty diff despite the instructions changing. Gate the suppression on the captured baseline instead of the Old-read error so every fallback without a baseline emits no entry, matching the system-prompt handler. A commit failure keeps its baseline and still emits the attempt row with its real diff. --- coderd/exp_chats.go | 13 +++++++----- coderd/exp_chats_test.go | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index cd3abddef97..0e9026a8fc8 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4856,11 +4856,14 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ }) return } - if oldReadErr != nil { - // The write landed through the direct path with no baseline, - // so no entry: warn and finish with the success response. - api.Logger.Warn(ctx, "audit old capture failed, writing plan mode instructions without an audit entry", - slog.Error(oldReadErr)) + 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 diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index ca0be73757e..1d2111b4cef 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -13655,6 +13655,50 @@ func TestChatPlanModeInstructions(t *testing.T) { 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