From d58705ee70660e874f2031d93d204eb0c3dff985 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 20 Aug 2026 10:05:47 +0000 Subject: [PATCH 1/5] feat: audit operational agent settings changes --- coderd/apidoc/docs.go | 6 +- coderd/apidoc/swagger.json | 6 +- coderd/audit/diff.go | 3 +- coderd/audit/request.go | 8 + coderd/database/dbauthz/dbauthz.go | 7 + coderd/database/dbauthz/dbauthz_test.go | 5 + coderd/database/dbmetrics/querymetrics.go | 8 + coderd/database/dbmock/dbmock.go | 15 + coderd/database/dump.sql | 3 +- ...4_audit_chat_operational_settings.down.sql | 1 + ...584_audit_chat_operational_settings.up.sql | 1 + coderd/database/models.go | 5 +- coderd/database/querier.go | 2 + coderd/database/querier_test.go | 32 ++ coderd/database/queries.sql.go | 30 ++ coderd/database/queries/siteconfig.sql | 17 + coderd/database/types.go | 12 + coderd/exp_chats.go | 186 +++++++++- coderd/exp_chats_test.go | 32 ++ codersdk/audit.go | 3 + docs/admin/security/audit-logs.md | 1 + docs/reference/api/schemas.md | 6 +- enterprise/audit/table.go | 11 + .../chat_operational_settings_audit_test.go | 335 ++++++++++++++++++ site/src/api/typesGenerated.ts | 2 + site/src/pages/AuditPage/AuditFilter.tsx | 4 + .../pages/AuditPage/AuditPageView.stories.tsx | 54 ++- 27 files changed, 776 insertions(+), 19 deletions(-) create mode 100644 coderd/database/migrations/000584_audit_chat_operational_settings.down.sql create mode 100644 coderd/database/migrations/000584_audit_chat_operational_settings.up.sql create mode 100644 enterprise/coderd/chat_operational_settings_audit_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 81bb7709f9eb9..fee70ca2cf526 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -26055,7 +26055,8 @@ const docTemplate = `{ "chat_model_config", "user_secret", "user_skill", - "chat_instruction_settings" + "chat_instruction_settings", + "chat_operational_settings" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -26096,7 +26097,8 @@ const docTemplate = `{ "ResourceTypeChatModelConfig", "ResourceTypeUserSecret", "ResourceTypeUserSkill", - "ResourceTypeChatInstructionSettings" + "ResourceTypeChatInstructionSettings", + "ResourceTypeChatOperationalSettings" ] }, "codersdk.Response": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0d53012fdbf74..d91dab0f5b151 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -23935,7 +23935,8 @@ "chat_model_config", "user_secret", "user_skill", - "chat_instruction_settings" + "chat_instruction_settings", + "chat_operational_settings" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -23976,7 +23977,8 @@ "ResourceTypeChatModelConfig", "ResourceTypeUserSecret", "ResourceTypeUserSkill", - "ResourceTypeChatInstructionSettings" + "ResourceTypeChatInstructionSettings", + "ResourceTypeChatOperationalSettings" ] }, "codersdk.Response": { diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 6fa6fc7ff4ba3..b26c0f6a68ffc 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -45,7 +45,8 @@ type Auditable interface { database.AuditableUserAIBudgetOverride | database.UserSecret | database.UserSkill | - database.ChatInstructionSettings + database.ChatInstructionSettings | + database.ChatOperationalSettings } // 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 03aae5856a748..26b0e2b6ff1c0 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -167,6 +167,8 @@ func ResourceTarget[T Auditable](tgt T) string { return typed.Name case database.ChatInstructionSettings: return typed.Name + case database.ChatOperationalSettings: + return "" default: panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt)) } @@ -275,6 +277,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { case database.ChatInstructionSettings: // Fixed ID per setting; see ChatInstructionSettings IDs. return typed.ID + case database.ChatOperationalSettings: + return typed.ID default: panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt)) } @@ -356,6 +360,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeUserSkill case database.ChatInstructionSettings: return database.ResourceTypeChatInstructionSettings + case database.ChatOperationalSettings: + return database.ResourceTypeChatOperationalSettings default: panic(fmt.Sprintf("unknown resource %T for ResourceType", typed)) } @@ -454,6 +460,8 @@ func ResourceRequiresOrgID[T Auditable]() bool { case database.ChatInstructionSettings: // Deployment settings, not scoped to any organization. return false + case database.ChatOperationalSettings: + return false default: panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt)) } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 32641625617d3..f31434ed08d9c 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3647,6 +3647,13 @@ func (q *querier) GetChatRetentionDays(ctx context.Context) (int32, error) { return q.db.GetChatRetentionDays(ctx) } +func (q *querier) GetChatSiteConfigValue(ctx context.Context, configKey string) (database.GetChatSiteConfigValueRow, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return database.GetChatSiteConfigValueRow{}, err + } + return q.db.GetChatSiteConfigValue(ctx, configKey) +} + func (q *querier) GetChatStreamSyncRows(ctx context.Context, ids []uuid.UUID) ([]database.GetChatStreamSyncRowsRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat); err != nil { return nil, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 5eab2778354e2..bea464f0b7299 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1358,6 +1358,11 @@ func (s *MethodTestSuite) TestChats() { check.Args(orgID).Asserts(objectA, policy.ActionRead, objectB, policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsByOrganizationRow{rowA, rowB}) })) + s.Run("GetChatSiteConfigValue", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + row := database.GetChatSiteConfigValueRow{Value: "30", Exists: true} + dbm.EXPECT().GetChatSiteConfigValue(gomock.Any(), "agents_chat_retention_days").Return(row, nil).AnyTimes() + check.Args("agents_chat_retention_days").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(row) + })) s.Run("GetStaleChats", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { threshold := dbtime.Now() chats := []database.Chat{testutil.Fake(s.T(), faker, database.Chat{})} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 1cba16aad4a5c..a3c4d42de19d4 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1792,6 +1792,14 @@ func (m queryMetricsStore) GetChatRetentionDays(ctx context.Context) (int32, err return r0, r1 } +func (m queryMetricsStore) GetChatSiteConfigValue(ctx context.Context, configKey string) (database.GetChatSiteConfigValueRow, error) { + start := time.Now() + r0, r1 := m.s.GetChatSiteConfigValue(ctx, configKey) + m.queryLatencies.WithLabelValues("GetChatSiteConfigValue").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatSiteConfigValue").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatStreamSyncRows(ctx context.Context, ids []uuid.UUID) ([]database.GetChatStreamSyncRowsRow, error) { start := time.Now() r0, r1 := m.s.GetChatStreamSyncRows(ctx, ids) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index f0630ba28c168..f4476bfe997e9 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3359,6 +3359,21 @@ func (mr *MockStoreMockRecorder) GetChatRetentionDays(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatRetentionDays", reflect.TypeOf((*MockStore)(nil).GetChatRetentionDays), ctx) } +// GetChatSiteConfigValue mocks base method. +func (m *MockStore) GetChatSiteConfigValue(ctx context.Context, configKey string) (database.GetChatSiteConfigValueRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatSiteConfigValue", ctx, configKey) + ret0, _ := ret[0].(database.GetChatSiteConfigValueRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatSiteConfigValue indicates an expected call of GetChatSiteConfigValue. +func (mr *MockStoreMockRecorder) GetChatSiteConfigValue(ctx, configKey any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatSiteConfigValue", reflect.TypeOf((*MockStore)(nil).GetChatSiteConfigValue), ctx, configKey) +} + // GetChatStreamSyncRows mocks base method. func (m *MockStore) GetChatStreamSyncRows(ctx context.Context, ids []uuid.UUID) ([]database.GetChatStreamSyncRowsRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 9bd7833dad75d..905b4d0082352 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -618,7 +618,8 @@ CREATE TYPE resource_type AS ENUM ( 'oauth2_provider_settings', 'chat_instruction_settings', 'mcp_server_config', - 'chat_model_config' + 'chat_model_config', + 'chat_operational_settings' ); CREATE TYPE shareable_workspace_owners AS ENUM ( diff --git a/coderd/database/migrations/000584_audit_chat_operational_settings.down.sql b/coderd/database/migrations/000584_audit_chat_operational_settings.down.sql new file mode 100644 index 0000000000000..5d93105daa6a9 --- /dev/null +++ b/coderd/database/migrations/000584_audit_chat_operational_settings.down.sql @@ -0,0 +1 @@ +-- PostgreSQL does not support removing enum values. diff --git a/coderd/database/migrations/000584_audit_chat_operational_settings.up.sql b/coderd/database/migrations/000584_audit_chat_operational_settings.up.sql new file mode 100644 index 0000000000000..1e7f88dce9891 --- /dev/null +++ b/coderd/database/migrations/000584_audit_chat_operational_settings.up.sql @@ -0,0 +1 @@ +ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'chat_operational_settings'; diff --git a/coderd/database/models.go b/coderd/database/models.go index a3d1ddaade3a2..3e1464dc07f2c 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3632,6 +3632,7 @@ const ( ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings" ResourceTypeMCPServerConfig ResourceType = "mcp_server_config" ResourceTypeChatModelConfig ResourceType = "chat_model_config" + ResourceTypeChatOperationalSettings ResourceType = "chat_operational_settings" ) func (e *ResourceType) Scan(src interface{}) error { @@ -3709,7 +3710,8 @@ func (e ResourceType) Valid() bool { ResourceTypeOauth2ProviderSettings, ResourceTypeChatInstructionSettings, ResourceTypeMCPServerConfig, - ResourceTypeChatModelConfig: + ResourceTypeChatModelConfig, + ResourceTypeChatOperationalSettings: return true } return false @@ -3756,6 +3758,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeChatInstructionSettings, ResourceTypeMCPServerConfig, ResourceTypeChatModelConfig, + ResourceTypeChatOperationalSettings, } } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 7e32ec91fce37..867d068445fb8 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -512,6 +512,8 @@ type sqlcQuerier interface { // dbpurge. Returns 30 (days) when no value has been configured. // A value of 0 disables chat purging entirely. GetChatRetentionDays(ctx context.Context) (int32, error) + // GetChatSiteConfigValue returns raw text and row presence for an audited chat site configuration. + GetChatSiteConfigValue(ctx context.Context, configKey string) (GetChatSiteConfigValueRow, error) GetChatStreamSyncRows(ctx context.Context, ids []uuid.UUID) ([]GetChatStreamSyncRowsRow, error) GetChatSystemPrompt(ctx context.Context) (string, error) // GetChatSystemPromptConfig returns both chat system prompt settings in a diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index aab0595e533f6..32d5bf246979f 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -19517,3 +19517,35 @@ func TestGetAIModelPrices(t *testing.T) { }) } } + +func TestGetChatSiteConfigValue(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + + require.NoError(t, db.UpsertRuntimeConfig(ctx, database.UpsertRuntimeConfigParams{ + Key: "agents_chat_retention_days", + Value: "30", + })) + require.NoError(t, db.UpsertRuntimeConfig(ctx, database.UpsertRuntimeConfigParams{ + Key: "agents_unset", + Value: "not a chat setting", + })) + require.NoError(t, db.UpsertRuntimeConfig(ctx, database.UpsertRuntimeConfigParams{ + Key: "derp_mesh_key", + Value: "secret", + })) + + value, err := db.GetChatSiteConfigValue(ctx, "agents_chat_retention_days") + require.NoError(t, err) + require.Equal(t, database.GetChatSiteConfigValueRow{Value: "30", Exists: true}, value) + + value, err = db.GetChatSiteConfigValue(ctx, "agents_unset") + require.NoError(t, err) + require.Equal(t, database.GetChatSiteConfigValueRow{}, value) + + value, err = db.GetChatSiteConfigValue(ctx, "derp_mesh_key") + require.NoError(t, err) + require.Equal(t, database.GetChatSiteConfigValueRow{}, value) +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 0ba38bb148dd9..ac731d6fd5b96 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -25678,6 +25678,36 @@ func (q *sqlQuerier) GetChatRetentionDays(ctx context.Context) (int32, error) { return retention_days, err } +const getChatSiteConfigValue = `-- name: GetChatSiteConfigValue :one +SELECT + COALESCE(MAX(site_configs.value), '')::text AS value, + COUNT(*) > 0 AS exists +FROM site_configs +WHERE site_configs.key = $1 + AND site_configs.key IN ( + 'agents_chat_retention_days', + 'agents_chat_debug_retention_days', + 'agents_chat_auto_archive_days', + 'agents_workspace_ttl', + 'agents_computer_use_provider', + 'agents_chat_debug_logging_allow_users', + 'agents_chat_personal_model_overrides_enabled' + ) +` + +type GetChatSiteConfigValueRow struct { + Value string `db:"value" json:"value"` + Exists bool `db:"exists" json:"exists"` +} + +// GetChatSiteConfigValue returns raw text and row presence for an audited chat site configuration. +func (q *sqlQuerier) GetChatSiteConfigValue(ctx context.Context, configKey string) (GetChatSiteConfigValueRow, error) { + row := q.db.QueryRowContext(ctx, getChatSiteConfigValue, configKey) + var i GetChatSiteConfigValueRow + err := row.Scan(&i.Value, &i.Exists) + return i, err +} + const getChatSystemPrompt = `-- name: GetChatSystemPrompt :one SELECT COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index ef499baf6d282..52f28e7c5f1ca 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -378,3 +378,20 @@ INSERT INTO site_configs (key, value) VALUES ('agents_chat_auto_archive_days', CAST(@auto_archive_days AS integer)::text) ON CONFLICT (key) DO UPDATE SET value = CAST(@auto_archive_days AS integer)::text WHERE site_configs.key = 'agents_chat_auto_archive_days'; + +-- GetChatSiteConfigValue returns raw text and row presence for an audited chat site configuration. +-- name: GetChatSiteConfigValue :one +SELECT + COALESCE(MAX(site_configs.value), '')::text AS value, + COUNT(*) > 0 AS exists +FROM site_configs +WHERE site_configs.key = sqlc.arg(config_key) + AND site_configs.key IN ( + 'agents_chat_retention_days', + 'agents_chat_debug_retention_days', + 'agents_chat_auto_archive_days', + 'agents_workspace_ttl', + 'agents_computer_use_provider', + 'agents_chat_debug_logging_allow_users', + 'agents_chat_personal_model_overrides_enabled' + ); diff --git a/coderd/database/types.go b/coderd/database/types.go index 185b300b83651..85c84d4cf1018 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -70,6 +70,18 @@ type ChatInstructionSettings struct { PlanModeInstructions string `db:"plan_mode_instructions" json:"plan_mode_instructions"` } +// ChatOperationalSettings contains deployment-wide chat settings for audit logging. +type ChatOperationalSettings struct { + ID uuid.UUID `db:"id" json:"id"` + ChatRetentionDays string `db:"chat_retention_days" json:"chat_retention_days"` + ChatDebugRetentionDays string `db:"chat_debug_retention_days" json:"chat_debug_retention_days"` + ChatAutoArchiveDays string `db:"chat_auto_archive_days" json:"chat_auto_archive_days"` + WorkspaceTTL string `db:"workspace_ttl" json:"workspace_ttl"` + ComputerUseProvider string `db:"computer_use_provider" json:"computer_use_provider"` + DebugLoggingAllowUsers string `db:"debug_logging_allow_users" json:"debug_logging_allow_users"` + PersonalModelOverridesEnabled string `db:"personal_model_overrides_enabled" json:"personal_model_overrides_enabled"` +} + type Actions []policy.Action func (a *Actions) Scan(src interface{}) error { diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 52923b6c0272d..3ef68b1a83720 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4787,6 +4787,104 @@ func (api *API) getChatPersonalModelOverridesAdminSettings(rw http.ResponseWrite }) } +type chatOperationalSetting string + +const ( + chatOperationalSettingChatRetentionDays chatOperationalSetting = "agents_chat_retention_days" + chatOperationalSettingChatDebugRetentionDays chatOperationalSetting = "agents_chat_debug_retention_days" + chatOperationalSettingChatAutoArchiveDays chatOperationalSetting = "agents_chat_auto_archive_days" + chatOperationalSettingWorkspaceTTL chatOperationalSetting = "agents_workspace_ttl" + chatOperationalSettingComputerUseProvider chatOperationalSetting = "agents_computer_use_provider" + chatOperationalSettingDebugLoggingAllowUsers chatOperationalSetting = "agents_chat_debug_logging_allow_users" + chatOperationalSettingPersonalModelOverridesEnabled chatOperationalSetting = "agents_chat_personal_model_overrides_enabled" +) + +func (s chatOperationalSetting) defaultValue() string { + switch s { + case chatOperationalSettingChatRetentionDays: + return "30" + case chatOperationalSettingChatDebugRetentionDays: + return strconv.FormatInt(int64(codersdk.DefaultChatDebugRetentionDays), 10) + case chatOperationalSettingChatAutoArchiveDays: + return strconv.FormatInt(int64(codersdk.DefaultChatAutoArchiveDays), 10) + case chatOperationalSettingWorkspaceTTL: + return "0s" + case chatOperationalSettingComputerUseProvider: + return "" + case chatOperationalSettingDebugLoggingAllowUsers, + chatOperationalSettingPersonalModelOverridesEnabled: + return "false" + default: + panic(fmt.Sprintf("unknown chat operational setting %q", s)) + } +} + +func (s chatOperationalSetting) auditValue(value string, id uuid.UUID) database.ChatOperationalSettings { + settings := database.ChatOperationalSettings{ID: id} + switch s { + case chatOperationalSettingChatRetentionDays: + settings.ChatRetentionDays = value + case chatOperationalSettingChatDebugRetentionDays: + settings.ChatDebugRetentionDays = value + case chatOperationalSettingChatAutoArchiveDays: + settings.ChatAutoArchiveDays = value + case chatOperationalSettingWorkspaceTTL: + settings.WorkspaceTTL = value + case chatOperationalSettingComputerUseProvider: + settings.ComputerUseProvider = value + case chatOperationalSettingDebugLoggingAllowUsers: + settings.DebugLoggingAllowUsers = value + case chatOperationalSettingPersonalModelOverridesEnabled: + settings.PersonalModelOverridesEnabled = value + default: + panic(fmt.Sprintf("unknown chat operational setting %q", s)) + } + return settings +} + +// auditedChatOperationalSettingWrite captures the raw old value and performs +// the write in one transaction. It suppresses the audit entry when the +// effective value does not change. +func (api *API) auditedChatOperationalSettingWrite( + ctx context.Context, + aReq *audit.Request[database.ChatOperationalSettings], + commitAudit func(bool), + setting chatOperationalSetting, + newValue string, + write func(database.Store) error, +) error { + var noChange bool + err := api.Database.InTx(func(tx database.Store) error { + old, err := tx.GetChatSiteConfigValue(ctx, string(setting)) + if err != nil { + return err + } + oldValue := old.Value + if !old.Exists { + oldValue = setting.defaultValue() + } + if err := write(tx); err != nil { + return err + } + + aReq.Old = setting.auditValue(oldValue, uuid.Nil) + aReq.New = setting.auditValue(newValue, uuid.New()) + noChange = oldValue == newValue + return nil + }, nil) + if err != nil { + api.Logger.Warn(ctx, "chat operational setting transaction failed", + slog.F("key", setting), + slog.Error(err), + ) + return err + } + if noChange { + commitAudit(false) + } + return nil +} + // EXPERIMENTAL: this endpoint is experimental and is subject to change. func (api *API) putChatPersonalModelOverridesAdminSettings(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -4795,11 +4893,21 @@ func (api *API) putChatPersonalModelOverridesAdminSettings(rw http.ResponseWrite return } + aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, + }) + defer commitAudit(true) + var req codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest if !httpapi.Read(ctx, rw, r, &req) { return } - if err := api.Database.UpsertChatPersonalModelOverridesEnabled(ctx, req.AllowUsers); err != nil { + err := api.auditedChatOperationalSettingWrite( + ctx, aReq, commitAudit, chatOperationalSettingPersonalModelOverridesEnabled, + strconv.FormatBool(req.AllowUsers), + func(tx database.Store) error { return tx.UpsertChatPersonalModelOverridesEnabled(ctx, req.AllowUsers) }, + ) + if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error updating personal model override setting.", Detail: err.Error(), @@ -5060,6 +5168,11 @@ func (api *API) putChatComputerUseProvider(rw http.ResponseWriter, r *http.Reque return } + aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, + }) + defer commitAudit(true) + var req codersdk.UpdateChatComputerUseProviderRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5076,7 +5189,12 @@ func (api *API) putChatComputerUseProvider(rw http.ResponseWriter, r *http.Reque return } - if err := api.Database.UpsertChatComputerUseProvider(ctx, string(req.Provider)); err != nil { + value := string(req.Provider) + err := api.auditedChatOperationalSettingWrite( + ctx, aReq, commitAudit, chatOperationalSettingComputerUseProvider, value, + func(tx database.Store) error { return tx.UpsertChatComputerUseProvider(ctx, value) }, + ) + if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error updating computer use provider.", Detail: err.Error(), @@ -5122,11 +5240,21 @@ func (api *API) putChatDebugLogging(rw http.ResponseWriter, r *http.Request) { return } + aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, + }) + defer commitAudit(true) + var req codersdk.UpdateChatDebugLoggingAllowUsersRequest if !httpapi.Read(ctx, rw, r, &req) { return } - if err := api.Database.UpsertChatDebugLoggingAllowUsers(ctx, req.AllowUsers); err != nil { + err := api.auditedChatOperationalSettingWrite( + ctx, aReq, commitAudit, chatOperationalSettingDebugLoggingAllowUsers, + strconv.FormatBool(req.AllowUsers), + func(tx database.Store) error { return tx.UpsertChatDebugLoggingAllowUsers(ctx, req.AllowUsers) }, + ) + if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error updating chat debug logging setting.", Detail: err.Error(), @@ -5346,6 +5474,11 @@ func (api *API) putChatWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return } + aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, + }) + defer commitAudit(true) + var req codersdk.UpdateChatWorkspaceTTLRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5377,8 +5510,12 @@ func (api *API) putChatWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return } - // Store the canonicalized duration string. - if err := api.Database.UpsertChatWorkspaceTTL(ctx, d.String()); httpapi.Is404Error(err) { + value := d.String() + err := api.auditedChatOperationalSettingWrite( + ctx, aReq, commitAudit, chatOperationalSettingWorkspaceTTL, value, + func(tx database.Store) error { return tx.UpsertChatWorkspaceTTL(ctx, value) }, + ) + if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return } else if err != nil { @@ -5435,6 +5572,12 @@ func (api *API) putChatRetentionDays(rw http.ResponseWriter, r *http.Request) { httpapi.Forbidden(rw) return } + + aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, + }) + defer commitAudit(true) + var req codersdk.UpdateChatRetentionDaysRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5445,7 +5588,12 @@ func (api *API) putChatRetentionDays(rw http.ResponseWriter, r *http.Request) { }) return } - if err := api.Database.UpsertChatRetentionDays(ctx, req.RetentionDays); err != nil { + value := strconv.FormatInt(int64(req.RetentionDays), 10) + err := api.auditedChatOperationalSettingWrite( + ctx, aReq, commitAudit, chatOperationalSettingChatRetentionDays, value, + func(tx database.Store) error { return tx.UpsertChatRetentionDays(ctx, req.RetentionDays) }, + ) + if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to update chat retention days.", Detail: err.Error(), @@ -5486,6 +5634,12 @@ func (api *API) putChatDebugRetentionDays(rw http.ResponseWriter, r *http.Reques httpapi.Forbidden(rw) return } + + aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, + }) + defer commitAudit(true) + var req codersdk.UpdateChatDebugRetentionDaysRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5496,7 +5650,12 @@ func (api *API) putChatDebugRetentionDays(rw http.ResponseWriter, r *http.Reques }) return } - if err := api.Database.UpsertChatDebugRetentionDays(ctx, req.DebugRetentionDays); err != nil { + value := strconv.FormatInt(int64(req.DebugRetentionDays), 10) + err := api.auditedChatOperationalSettingWrite( + ctx, aReq, commitAudit, chatOperationalSettingChatDebugRetentionDays, value, + func(tx database.Store) error { return tx.UpsertChatDebugRetentionDays(ctx, req.DebugRetentionDays) }, + ) + if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to update chat debug retention days.", Detail: err.Error(), @@ -5538,6 +5697,12 @@ func (api *API) putChatAutoArchiveDays(rw http.ResponseWriter, r *http.Request) httpapi.Forbidden(rw) return } + + aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, + }) + defer commitAudit(true) + var req codersdk.UpdateChatAutoArchiveDaysRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5548,7 +5713,12 @@ func (api *API) putChatAutoArchiveDays(rw http.ResponseWriter, r *http.Request) }) return } - if err := api.Database.UpsertChatAutoArchiveDays(ctx, req.AutoArchiveDays); err != nil { + value := strconv.FormatInt(int64(req.AutoArchiveDays), 10) + err := api.auditedChatOperationalSettingWrite( + ctx, aReq, commitAudit, chatOperationalSettingChatAutoArchiveDays, value, + func(tx database.Store) error { return tx.UpsertChatAutoArchiveDays(ctx, req.AutoArchiveDays) }, + ) + if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to update chat auto-archive days.", Detail: err.Error(), diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index cd1b1f457e0d1..4a683080a5082 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -633,6 +633,38 @@ func (s *chatModelConfigHookStore) GetChatModelConfigByID( return s.Store.GetChatModelConfigByID(ctx, id) } +type failNextGetChatSiteConfigValueStore struct { + database.Store + + failNextGetChatSiteConfigValue *atomic.Bool +} + +func newFailNextGetChatSiteConfigValueStore(store database.Store) *failNextGetChatSiteConfigValueStore { + return &failNextGetChatSiteConfigValueStore{ + Store: store, + failNextGetChatSiteConfigValue: &atomic.Bool{}, + } +} + +func (s *failNextGetChatSiteConfigValueStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return function(&failNextGetChatSiteConfigValueStore{ + Store: tx, + failNextGetChatSiteConfigValue: s.failNextGetChatSiteConfigValue, + }) + }, txOpts) +} + +func (s *failNextGetChatSiteConfigValueStore) GetChatSiteConfigValue( + ctx context.Context, + configKey string, +) (database.GetChatSiteConfigValueRow, error) { + if s.failNextGetChatSiteConfigValue.CompareAndSwap(true, false) { + return database.GetChatSiteConfigValueRow{}, stderrors.New("forced chat site configuration read failure") + } + return s.Store.GetChatSiteConfigValue(ctx, configKey) +} + func insertAssistantMessage( t *testing.T, db database.Store, diff --git a/codersdk/audit.go b/codersdk/audit.go index d95dc681ab375..83ac629bccc24 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -58,6 +58,7 @@ const ( ResourceTypeUserSecret ResourceType = "user_secret" ResourceTypeUserSkill ResourceType = "user_skill" ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings" + ResourceTypeChatOperationalSettings ResourceType = "chat_operational_settings" ) func (r ResourceType) FriendlyString() string { @@ -142,6 +143,8 @@ func (r ResourceType) FriendlyString() string { return "user skill" case ResourceTypeChatInstructionSettings: return "chat instruction settings" + case ResourceTypeChatOperationalSettings: + return "chat operational settings" default: return "unknown" } diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 519da9dae9dfd..214cc2cb0de94 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -28,6 +28,7 @@ We track the following resources: | Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
compaction_requested_atfalse
context_aggregate_hashfalse
context_dirty_resourcesfalse
context_dirty_sincefalse
context_errorfalse
created_atfalse
dynamic_toolsfalse
generation_attemptfalse
group_acltrue
heartbeat_atfalse
history_versionfalse
idtrue
labelstrue
last_errorfalse
last_model_config_idfalse
last_read_message_idfalse
last_reasoning_effortfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
queue_versionfalse
requires_action_deadline_atfalse
retry_statefalse
retry_state_versionfalse
root_chat_idfalse
runner_idfalse
snapshot_versionfalse
started_atfalse
statusfalse
summaryfalse
summary_generated_atfalse
titletrue
updated_atfalse
user_acltrue
worker_idfalse
workspace_idtrue
| | ChatInstructionSettings
write | |
FieldTracked
idfalse
include_default_system_prompttrue
include_default_system_prompt_settrue
namefalse
plan_mode_instructionstrue
system_prompttrue
| | ChatModelConfig
create, write, delete | |
FieldTracked
ai_provider_idtrue
compression_thresholdtrue
context_limittrue
created_atfalse
created_bytrue
deletedtrue
deleted_atfalse
display_nametrue
enabledtrue
group_acltrue
idfalse
is_defaulttrue
modeltrue
optionstrue
organization_idfalse
updated_atfalse
updated_bytrue
user_acltrue
| +| ChatOperationalSettings
write | |
FieldTracked
chat_auto_archive_daystrue
chat_debug_retention_daystrue
chat_retention_daystrue
computer_use_providertrue
debug_logging_allow_userstrue
idfalse
personal_model_overrides_enabledtrue
workspace_ttltrue
| | 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 a757b639485e8..fbc535c486364 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -13104,9 +13104,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_instruction_settings`, `chat_model_config`, `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`, `mcp_server_config`, `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`, `chat_model_config`, `chat_operational_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`, `mcp_server_config`, `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/table.go b/enterprise/audit/table.go index 6eaecba4b7613..a0963914d1e49 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -40,6 +40,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{ "UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "ChatInstructionSettings": {codersdk.AuditActionWrite}, + "ChatOperationalSettings": {codersdk.AuditActionWrite}, } type Action string @@ -568,6 +569,16 @@ var auditableResourcesTypes = map[any]map[string]Action{ "created_at": ActionIgnore, "updated_at": ActionIgnore, }, + &database.ChatOperationalSettings{}: { + "id": ActionIgnore, + "chat_retention_days": ActionTrack, + "chat_debug_retention_days": ActionTrack, + "chat_auto_archive_days": ActionTrack, + "workspace_ttl": ActionTrack, + "computer_use_provider": ActionTrack, + "debug_logging_allow_users": ActionTrack, + "personal_model_overrides_enabled": ActionTrack, + }, &database.UserSecret{}: { "id": ActionTrack, "user_id": ActionTrack, diff --git a/enterprise/coderd/chat_operational_settings_audit_test.go b/enterprise/coderd/chat_operational_settings_audit_test.go new file mode 100644 index 0000000000000..4d88a12acb15b --- /dev/null +++ b/enterprise/coderd/chat_operational_settings_audit_test.go @@ -0,0 +1,335 @@ +package coderd_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" + entaudit "github.com/coder/coder/v2/enterprise/audit" + "github.com/coder/coder/v2/enterprise/audit/backends" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +type chatOperationalSettingsAuditFixture struct { + db database.Store + client *codersdk.ExperimentalClient + ctx context.Context + systemCtx context.Context +} + +type chatOperationalSettingAuditCase struct { + name string + key string + diffField string + oldValue string + newValue string + write func(context.Context, *codersdk.ExperimentalClient) error +} + +func newChatOperationalSettingsAuditFixture(t *testing.T) chatOperationalSettingsAuditFixture { + t.Helper() + + db, ps := dbtestutil.NewDB(t) + auditor := entaudit.NewAuditor(db, entaudit.DefaultFilter, backends.NewPostgres(db, true)) + ownerClient, _ := coderdenttest.New(t, &coderdenttest.Options{ + AuditLogging: true, + Options: &coderdtest.Options{ + Database: db, + Pubsub: ps, + Auditor: auditor, + DeploymentValues: coderdtest.DeploymentValues(t, func(values *codersdk.DeploymentValues) { + values.Experiments = []string{string(codersdk.ExperimentChatVirtualDesktop)} + }), + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureAuditLog: 1}, + }, + }) + ctx := testutil.Context(t, testutil.WaitLong) + return chatOperationalSettingsAuditFixture{ + db: db, + client: codersdk.NewExperimentalClient(ownerClient), + ctx: ctx, + systemCtx: dbauthz.AsSystemRestricted(ctx), + } +} + +func (f chatOperationalSettingsAuditFixture) auditLogs(t *testing.T) []database.GetAuditLogsOffsetRow { + t.Helper() + + rows, err := f.db.GetAuditLogsOffset(f.systemCtx, database.GetAuditLogsOffsetParams{ + ResourceType: string(database.ResourceTypeChatOperationalSettings), + LimitOpt: 10, + }) + require.NoError(t, err) + return rows +} + +func (f chatOperationalSettingsAuditFixture) onlyAuditLog(t *testing.T) database.AuditLog { + t.Helper() + + rows := f.auditLogs(t) + require.Len(t, rows, 1) + return rows[0].AuditLog +} + +func requireChatOperationalSettingsAuditDiff(t *testing.T, log database.AuditLog, field, oldValue, newValue string) { + t.Helper() + + var diff audit.Map + require.NoError(t, json.Unmarshal(log.Diff, &diff)) + require.Equal(t, audit.Map{ + field: {Old: oldValue, New: newValue}, + }, diff) +} + +func TestChatOperationalSettingsAudit(t *testing.T) { + t.Parallel() + + retentionDays := chatOperationalSettingAuditCase{ + name: "RetentionDays", + key: "agents_chat_retention_days", + diffField: "chat_retention_days", + oldValue: "30", + newValue: "47", + write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + return client.UpdateChatRetentionDays(ctx, codersdk.UpdateChatRetentionDaysRequest{RetentionDays: 47}) + }, + } + debugRetentionDays := chatOperationalSettingAuditCase{ + name: "DebugRetentionDays", + key: "agents_chat_debug_retention_days", + diffField: "chat_debug_retention_days", + oldValue: "7", + newValue: "47", + write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + return client.UpdateChatDebugRetentionDays(ctx, codersdk.UpdateChatDebugRetentionDaysRequest{DebugRetentionDays: 47}) + }, + } + autoArchiveDays := chatOperationalSettingAuditCase{ + name: "AutoArchiveDays", + key: "agents_chat_auto_archive_days", + diffField: "chat_auto_archive_days", + oldValue: "14", + newValue: "47", + write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + return client.UpdateChatAutoArchiveDays(ctx, codersdk.UpdateChatAutoArchiveDaysRequest{AutoArchiveDays: 47}) + }, + } + workspaceTTL := chatOperationalSettingAuditCase{ + name: "WorkspaceTTL", + key: "agents_workspace_ttl", + diffField: "workspace_ttl", + oldValue: "1h0m0s", + newValue: "2h0m0s", + write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + return client.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ + WorkspaceTTLMillis: (2 * time.Hour).Milliseconds(), + }) + }, + } + computerUseProvider := chatOperationalSettingAuditCase{ + name: "ComputerUseProvider", + key: "agents_computer_use_provider", + diffField: "computer_use_provider", + oldValue: string(codersdk.ChatComputerUseProviderAnthropic), + newValue: string(codersdk.ChatComputerUseProviderOpenAI), + write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + return client.UpdateChatComputerUseProvider(ctx, codersdk.UpdateChatComputerUseProviderRequest{ + Provider: codersdk.ChatComputerUseProviderOpenAI, + }) + }, + } + debugLogging := chatOperationalSettingAuditCase{ + name: "DebugLogging", + key: "agents_chat_debug_logging_allow_users", + diffField: "debug_logging_allow_users", + oldValue: "false", + newValue: "true", + write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + return client.UpdateChatDebugLogging(ctx, codersdk.UpdateChatDebugLoggingAllowUsersRequest{AllowUsers: true}) + }, + } + personalModelOverrides := chatOperationalSettingAuditCase{ + name: "PersonalModelOverrides", + key: "agents_chat_personal_model_overrides_enabled", + diffField: "personal_model_overrides_enabled", + oldValue: "false", + newValue: "true", + write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + return client.UpdateChatPersonalModelOverridesAdminSettings(ctx, codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest{ + AllowUsers: true, + }) + }, + } + + typedFields := []chatOperationalSettingAuditCase{ + retentionDays, + debugRetentionDays, + autoArchiveDays, + workspaceTTL, + computerUseProvider, + debugLogging, + personalModelOverrides, + } + for _, setting := range typedFields { + t.Run(setting.name, func(t *testing.T) { + t.Parallel() + + fixture := newChatOperationalSettingsAuditFixture(t) + require.NoError(t, fixture.db.UpsertRuntimeConfig(fixture.systemCtx, database.UpsertRuntimeConfigParams{ + Key: setting.key, + Value: setting.oldValue, + })) + require.NoError(t, setting.write(fixture.ctx, fixture.client)) + + log := fixture.onlyAuditLog(t) + requireChatOperationalSettingsAuditDiff(t, log, setting.diffField, setting.oldValue, setting.newValue) + }) + } + + effectiveDefaults := []struct { + name string + key string + write func(context.Context, *codersdk.ExperimentalClient) error + }{ + { + name: "RetentionDaysEffectiveDefault", + key: retentionDays.key, + write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + return client.UpdateChatRetentionDays(ctx, codersdk.UpdateChatRetentionDaysRequest{RetentionDays: 30}) + }, + }, + { + name: "DebugRetentionDaysEffectiveDefault", + key: debugRetentionDays.key, + write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + return client.UpdateChatDebugRetentionDays(ctx, codersdk.UpdateChatDebugRetentionDaysRequest{ + DebugRetentionDays: codersdk.DefaultChatDebugRetentionDays, + }) + }, + }, + { + name: "AutoArchiveDaysEffectiveDefault", + key: autoArchiveDays.key, + write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + return client.UpdateChatAutoArchiveDays(ctx, codersdk.UpdateChatAutoArchiveDaysRequest{ + AutoArchiveDays: codersdk.DefaultChatAutoArchiveDays, + }) + }, + }, + { + name: "WorkspaceTTLEffectiveDefault", + key: workspaceTTL.key, + write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + return client.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ + WorkspaceTTLMillis: codersdk.DefaultChatWorkspaceTTL, + }) + }, + }, + { + name: "DebugLoggingEffectiveDefault", + key: debugLogging.key, + write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + return client.UpdateChatDebugLogging(ctx, codersdk.UpdateChatDebugLoggingAllowUsersRequest{AllowUsers: false}) + }, + }, + { + name: "PersonalModelOverridesEffectiveDefault", + key: personalModelOverrides.key, + write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + return client.UpdateChatPersonalModelOverridesAdminSettings(ctx, codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest{ + AllowUsers: false, + }) + }, + }, + } + for _, setting := range effectiveDefaults { + t.Run(setting.name, func(t *testing.T) { + t.Parallel() + + fixture := newChatOperationalSettingsAuditFixture(t) + require.NoError(t, fixture.db.DeleteRuntimeConfig(fixture.systemCtx, setting.key)) + require.NoError(t, setting.write(fixture.ctx, fixture.client)) + require.Empty(t, fixture.auditLogs(t)) + }) + } + + t.Run("ComputerUseProviderEmptyEffectiveDefault", func(t *testing.T) { + t.Parallel() + + fixture := newChatOperationalSettingsAuditFixture(t) + require.NoError(t, fixture.db.DeleteRuntimeConfig(fixture.systemCtx, computerUseProvider.key)) + require.NoError(t, computerUseProvider.write(fixture.ctx, fixture.client)) + + log := fixture.onlyAuditLog(t) + requireChatOperationalSettingsAuditDiff(t, log, computerUseProvider.diffField, "", computerUseProvider.newValue) + }) + + t.Run("CommonMetadata", func(t *testing.T) { + t.Parallel() + + fixture := newChatOperationalSettingsAuditFixture(t) + require.NoError(t, fixture.db.UpsertRuntimeConfig(fixture.systemCtx, database.UpsertRuntimeConfigParams{ + Key: retentionDays.key, + Value: retentionDays.oldValue, + })) + require.NoError(t, retentionDays.write(fixture.ctx, fixture.client)) + + log := fixture.onlyAuditLog(t) + require.Equal(t, database.AuditActionWrite, log.Action) + require.Equal(t, database.ResourceTypeChatOperationalSettings, log.ResourceType) + require.Empty(t, log.ResourceTarget) + require.NotEqual(t, uuid.Nil, log.ResourceID) + require.Equal(t, uuid.Nil, log.OrganizationID) + require.EqualValues(t, 204, log.StatusCode) + }) + + t.Run("IdenticalWrite", func(t *testing.T) { + t.Parallel() + + fixture := newChatOperationalSettingsAuditFixture(t) + require.NoError(t, fixture.db.UpsertRuntimeConfig(fixture.systemCtx, database.UpsertRuntimeConfigParams{ + Key: debugLogging.key, + Value: debugLogging.newValue, + })) + require.NoError(t, debugLogging.write(fixture.ctx, fixture.client)) + require.Empty(t, fixture.auditLogs(t)) + }) + + t.Run("InvalidWrite", func(t *testing.T) { + t.Parallel() + + fixture := newChatOperationalSettingsAuditFixture(t) + err := fixture.client.UpdateChatRetentionDays(fixture.ctx, codersdk.UpdateChatRetentionDaysRequest{RetentionDays: -1}) + require.Error(t, err) + require.Empty(t, fixture.auditLogs(t)) + }) + + t.Run("MalformedRawValueRepair", func(t *testing.T) { + t.Parallel() + + fixture := newChatOperationalSettingsAuditFixture(t) + const malformed = "not-a-number" + require.NoError(t, fixture.db.UpsertRuntimeConfig(fixture.systemCtx, database.UpsertRuntimeConfigParams{ + Key: retentionDays.key, + Value: malformed, + })) + require.NoError(t, fixture.client.UpdateChatRetentionDays(fixture.ctx, codersdk.UpdateChatRetentionDaysRequest{RetentionDays: 60})) + + log := fixture.onlyAuditLog(t) + requireChatOperationalSettingsAuditDiff(t, log, retentionDays.diffField, malformed, "60") + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9e73794b0166f..837dd91aed23b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -8117,6 +8117,7 @@ export type ResourceType = | "chat" | "chat_instruction_settings" | "chat_model_config" + | "chat_operational_settings" | "convert_login" | "custom_role" | "git_ssh_key" @@ -8158,6 +8159,7 @@ export const ResourceTypes: ResourceType[] = [ "chat", "chat_instruction_settings", "chat_model_config", + "chat_operational_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 fa527dccc4cf0..edf178c55787c 100644 --- a/site/src/pages/AuditPage/AuditFilter.tsx +++ b/site/src/pages/AuditPage/AuditFilter.tsx @@ -159,6 +159,10 @@ export const useResourceTypeFilterMenu = ({ label = "Chat Instruction Settings"; } + if (type === "chat_operational_settings") { + label = "Chat Operational Settings"; + } + return { value: type, label, diff --git a/site/src/pages/AuditPage/AuditPageView.stories.tsx b/site/src/pages/AuditPage/AuditPageView.stories.tsx index 030ed84c0c58f..249f153891999 100644 --- a/site/src/pages/AuditPage/AuditPageView.stories.tsx +++ b/site/src/pages/AuditPage/AuditPageView.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { ComponentProps } from "react"; +import { type ComponentProps, useState } from "react"; import { expect, fn, screen, userEvent, within } from "storybook/test"; import { getDefaultFilterProps, @@ -61,6 +61,58 @@ export const AuditPage: Story = { }, }; +const AuditPageWithResourceTypeFilter = ( + props: ComponentProps, +) => { + const [resourceType, setResourceType] = useState(); + const resourceTypeMenu = useResourceTypeFilterMenu({ + value: resourceType, + onChange: (option) => setResourceType(option?.value), + }); + + return ( + + ); +}; + +export const ChatOperationalSettingsFilter: Story = { + args: { + auditsQuery: mockSuccessResult, + }, + render: (args) => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const resourceTypeFilter = canvas.getByRole("button", { + name: "Select a resource type", + }); + await userEvent.click(resourceTypeFilter); + + const option = await screen.findByRole("option", { + name: "Chat Operational Settings", + }); + await userEvent.click(option); + await expect(resourceTypeFilter).toHaveTextContent( + "Chat Operational Settings", + ); + }, +}; + export const Loading: Story = { args: { auditLogs: undefined, From 66294caec5b8ab54e75f7713186d3f27370aa8a4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 20 Aug 2026 14:51:30 +0000 Subject: [PATCH 2/5] fix: prevent unaudited chat settings writes --- enterprise/coderd/chat_operational_settings_audit_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/enterprise/coderd/chat_operational_settings_audit_test.go b/enterprise/coderd/chat_operational_settings_audit_test.go index 4d88a12acb15b..4b5d0fa966445 100644 --- a/enterprise/coderd/chat_operational_settings_audit_test.go +++ b/enterprise/coderd/chat_operational_settings_audit_test.go @@ -315,7 +315,10 @@ func TestChatOperationalSettingsAudit(t *testing.T) { fixture := newChatOperationalSettingsAuditFixture(t) err := fixture.client.UpdateChatRetentionDays(fixture.ctx, codersdk.UpdateChatRetentionDaysRequest{RetentionDays: -1}) require.Error(t, err) - require.Empty(t, fixture.auditLogs(t)) + + log := fixture.onlyAuditLog(t) + require.EqualValues(t, 400, log.StatusCode) + require.JSONEq(t, "{}", string(log.Diff)) }) t.Run("MalformedRawValueRepair", func(t *testing.T) { From b6e92752ba334abb8d60972f735031bf9e5aaae2 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 20 Aug 2026 15:34:45 +0000 Subject: [PATCH 3/5] fix(coderd): skip operational setting no-op writes --- coderd/exp_chats.go | 93 +++--- coderd/exp_chats_test.go | 251 ++++++++++++++++ .../chat_operational_settings_audit_test.go | 278 ++++++++---------- .../pages/AuditPage/AuditPageView.stories.tsx | 85 ++---- 4 files changed, 450 insertions(+), 257 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 3ef68b1a83720..00409d333ed8e 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4787,6 +4787,8 @@ func (api *API) getChatPersonalModelOverridesAdminSettings(rw http.ResponseWrite }) } +const chatOperationalSettingsLockTimeout = 5 * time.Second + type chatOperationalSetting string const ( @@ -4842,9 +4844,20 @@ func (s chatOperationalSetting) auditValue(value string, id uuid.UUID) database. return settings } -// auditedChatOperationalSettingWrite captures the raw old value and performs -// the write in one transaction. It suppresses the audit entry when the -// effective value does not change. +func (api *API) initChatOperationalSettingsAudit( + rw http.ResponseWriter, + r *http.Request, +) (*audit.Request[database.ChatOperationalSettings], func(bool)) { + aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, + }) + aReq.New.ID = uuid.New() + return aReq, commitAudit +} + +// auditedChatOperationalSettingWrite captures the effective old value and +// performs the write in one transaction. It suppresses the audit entry when +// the effective value does not change. func (api *API) auditedChatOperationalSettingWrite( ctx context.Context, aReq *audit.Request[database.ChatOperationalSettings], @@ -4854,7 +4867,14 @@ func (api *API) auditedChatOperationalSettingWrite( write func(database.Store) error, ) error { var noChange bool + lockCtx, lockCancel := context.WithTimeout(ctx, chatOperationalSettingsLockTimeout) + defer lockCancel() + err := api.Database.InTx(func(tx database.Store) error { + if err := tx.AcquireLock(lockCtx, database.GenLockID(string(setting))); err != nil { + return xerrors.Errorf("acquire chat operational setting write lock: %w", err) + } + old, err := tx.GetChatSiteConfigValue(ctx, string(setting)) if err != nil { return err @@ -4863,20 +4883,19 @@ func (api *API) auditedChatOperationalSettingWrite( if !old.Exists { oldValue = setting.defaultValue() } + if oldValue == newValue { + noChange = true + return nil + } if err := write(tx); err != nil { return err } aReq.Old = setting.auditValue(oldValue, uuid.Nil) - aReq.New = setting.auditValue(newValue, uuid.New()) - noChange = oldValue == newValue + aReq.New = setting.auditValue(newValue, aReq.New.ID) return nil }, nil) if err != nil { - api.Logger.Warn(ctx, "chat operational setting transaction failed", - slog.F("key", setting), - slog.Error(err), - ) return err } if noChange { @@ -4888,16 +4907,14 @@ func (api *API) auditedChatOperationalSettingWrite( // EXPERIMENTAL: this endpoint is experimental and is subject to change. func (api *API) putChatPersonalModelOverridesAdminSettings(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r) + defer commitAudit(true) + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { httpapi.Forbidden(rw) return } - aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ - Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, - }) - defer commitAudit(true) - var req codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5163,16 +5180,14 @@ func (api *API) getChatComputerUseProvider(rw http.ResponseWriter, r *http.Reque // EXPERIMENTAL: this endpoint is experimental and is subject to change. func (api *API) putChatComputerUseProvider(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r) + defer commitAudit(true) + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { httpapi.Forbidden(rw) return } - aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ - Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, - }) - defer commitAudit(true) - var req codersdk.UpdateChatComputerUseProviderRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5235,16 +5250,14 @@ func (api *API) getChatDebugLogging(rw http.ResponseWriter, r *http.Request) { // EXPERIMENTAL: this endpoint is experimental and is subject to change. func (api *API) putChatDebugLogging(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r) + defer commitAudit(true) + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { httpapi.Forbidden(rw) return } - aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ - Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, - }) - defer commitAudit(true) - var req codersdk.UpdateChatDebugLoggingAllowUsersRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5469,16 +5482,14 @@ func (api *API) getChatWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { // EXPERIMENTAL: this endpoint is experimental and is subject to change. func (api *API) putChatWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r) + defer commitAudit(true) + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { httpapi.Forbidden(rw) return } - aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ - Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, - }) - defer commitAudit(true) - var req codersdk.UpdateChatWorkspaceTTLRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5568,16 +5579,14 @@ const retentionDaysMaximum = 3650 // ~10 years // @x-apidocgen {"skip": true} func (api *API) putChatRetentionDays(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r) + defer commitAudit(true) + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { httpapi.Forbidden(rw) return } - aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ - Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, - }) - defer commitAudit(true) - var req codersdk.UpdateChatRetentionDaysRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5630,16 +5639,14 @@ const chatDebugRetentionDaysMaximum = 3650 // ~10 years // retention window. Admin-only. func (api *API) putChatDebugRetentionDays(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r) + defer commitAudit(true) + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { httpapi.Forbidden(rw) return } - aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ - Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, - }) - defer commitAudit(true) - var req codersdk.UpdateChatDebugRetentionDaysRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5693,16 +5700,14 @@ const autoArchiveDaysMaximum = 3650 // ~10 years // window. Admin-only; documented in docs/ai-coder/agents/chats-api.md. func (api *API) putChatAutoArchiveDays(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r) + defer commitAudit(true) + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { httpapi.Forbidden(rw) return } - aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{ - Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite, - }) - defer commitAudit(true) - var req codersdk.UpdateChatAutoArchiveDaysRequest if !httpapi.Read(ctx, rw, r, &req) { return diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 4a683080a5082..5e4c0499d4e1e 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -639,6 +639,63 @@ type failNextGetChatSiteConfigValueStore struct { failNextGetChatSiteConfigValue *atomic.Bool } +type failNextChatOperationalSettingTransactionStore struct { + database.Store + + failNextTransaction *atomic.Bool +} + +func newFailNextChatOperationalSettingTransactionStore(store database.Store) *failNextChatOperationalSettingTransactionStore { + return &failNextChatOperationalSettingTransactionStore{ + Store: store, + failNextTransaction: &atomic.Bool{}, + } +} + +func (s *failNextChatOperationalSettingTransactionStore) InTx( + function func(database.Store) error, + txOpts *database.TxOptions, +) error { + return s.Store.InTx(func(tx database.Store) error { + if err := function(tx); err != nil { + return err + } + if s.failNextTransaction.CompareAndSwap(true, false) { + return stderrors.New("forced chat operational setting transaction failure") + } + return nil + }, txOpts) +} + +type failNextUpsertChatRetentionDaysStore struct { + database.Store + + failNextUpsert *atomic.Bool +} + +func newFailNextUpsertChatRetentionDaysStore(store database.Store) *failNextUpsertChatRetentionDaysStore { + return &failNextUpsertChatRetentionDaysStore{ + Store: store, + failNextUpsert: &atomic.Bool{}, + } +} + +func (s *failNextUpsertChatRetentionDaysStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return function(&failNextUpsertChatRetentionDaysStore{ + Store: tx, + failNextUpsert: s.failNextUpsert, + }) + }, txOpts) +} + +func (s *failNextUpsertChatRetentionDaysStore) UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error { + if s.failNextUpsert.CompareAndSwap(true, false) { + return stderrors.New("forced chat retention days upsert failure") + } + return s.Store.UpsertChatRetentionDays(ctx, retentionDays) +} + func newFailNextGetChatSiteConfigValueStore(store database.Store) *failNextGetChatSiteConfigValueStore { return &failNextGetChatSiteConfigValueStore{ Store: store, @@ -16850,6 +16907,200 @@ func TestChatWorkspaceTTL(t *testing.T) { requireSDKError(t, err, http.StatusBadRequest) } +//nolint:tparallel,paralleltest // Subtests share one auditor and coderdtest instance. +func TestChatOperationalSettingsAuditDeniedWrites(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + mAudit := audit.NewMock() + adminClient := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + writes := map[string]func() error{ + "RetentionDays": func() error { + return memberClient.UpdateChatRetentionDays(ctx, codersdk.UpdateChatRetentionDaysRequest{RetentionDays: 90}) + }, + "DebugRetentionDays": func() error { + return memberClient.UpdateChatDebugRetentionDays(ctx, codersdk.UpdateChatDebugRetentionDaysRequest{DebugRetentionDays: 90}) + }, + "AutoArchiveDays": func() error { + return memberClient.UpdateChatAutoArchiveDays(ctx, codersdk.UpdateChatAutoArchiveDaysRequest{AutoArchiveDays: 90}) + }, + "WorkspaceTTL": func() error { + return memberClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{WorkspaceTTLMillis: time.Hour.Milliseconds()}) + }, + "ComputerUseProvider": func() error { + return memberClient.UpdateChatComputerUseProvider(ctx, codersdk.UpdateChatComputerUseProviderRequest{Provider: codersdk.ChatComputerUseProviderOpenAI}) + }, + "DebugLogging": func() error { + return memberClient.UpdateChatDebugLogging(ctx, codersdk.UpdateChatDebugLoggingAllowUsersRequest{AllowUsers: true}) + }, + "PersonalModelOverrides": func() error { + return memberClient.UpdateChatPersonalModelOverridesAdminSettings(ctx, codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest{AllowUsers: true}) + }, + } + for name, write := range writes { + t.Run(name, func(t *testing.T) { + mAudit.ResetLogs() + + err := write() + requireSDKError(t, err, http.StatusForbidden) + + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, database.ResourceTypeChatOperationalSettings, logs[0].ResourceType) + require.NotEqual(t, uuid.Nil, logs[0].ResourceID) + require.Empty(t, logs[0].ResourceTarget) + require.EqualValues(t, http.StatusForbidden, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) + }) + } +} + +func TestChatRetentionDays_AuditNoOpSkipsWrite(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newFailNextUpsertChatRetentionDaysStore(rawDB) + mAudit := audit.NewMock() + adminClient := newChatClient(t, func(opts *coderdtest.Options) { + opts.Database = store + opts.Pubsub = pubsub + opts.Auditor = mAudit + }) + coderdtest.CreateFirstUser(t, adminClient.Client) + mAudit.ResetLogs() + + require.NoError(t, rawDB.UpsertChatRetentionDays(ctx, 47)) + store.failNextUpsert.Store(true) + require.NoError(t, adminClient.UpdateChatRetentionDays(ctx, codersdk.UpdateChatRetentionDaysRequest{ + RetentionDays: 47, + })) + + require.True(t, store.failNextUpsert.Load(), "the no-op request must not execute the upsert") + stored, err := rawDB.GetChatSiteConfigValue(ctx, "agents_chat_retention_days") + require.NoError(t, err) + require.Equal(t, database.GetChatSiteConfigValueRow{Value: "47", Exists: true}, stored) + require.Empty(t, mAudit.AuditLogs()) +} + +func TestChatRetentionDays_AuditInfrastructureFailureRejectsWrite(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + retentionDays int32 + store func(database.Store) (database.Store, func()) + }{ + { + name: "BaselineRead", + retentionDays: 90, + store: func(db database.Store) (database.Store, func()) { + store := newFailNextGetChatSiteConfigValueStore(db) + return store, func() { store.failNextGetChatSiteConfigValue.Store(true) } + }, + }, + { + name: "Lock", + retentionDays: 90, + store: func(db database.Store) (database.Store, func()) { + store := newFailNextAcquireLockStore(db, database.GenLockID("agents_chat_retention_days")) + return store, func() { store.failNextAcquireLock.Store(true) } + }, + }, + { + name: "TransactionAfterNoChange", + retentionDays: 30, + store: func(db database.Store) (database.Store, func()) { + store := newFailNextChatOperationalSettingTransactionStore(db) + return store, func() { store.failNextTransaction.Store(true) } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + rawDB, pubsub := dbtestutil.NewDB(t) + store, fail := tt.store(rawDB) + mAudit := audit.NewMock() + adminClient := newChatClient(t, func(opts *coderdtest.Options) { + opts.Database = store + opts.Pubsub = pubsub + opts.Auditor = mAudit + }) + coderdtest.CreateFirstUser(t, adminClient.Client) + mAudit.ResetLogs() + + before, err := rawDB.GetChatSiteConfigValue(ctx, "agents_chat_retention_days") + require.NoError(t, err) + + fail() + err = adminClient.UpdateChatRetentionDays(ctx, codersdk.UpdateChatRetentionDaysRequest{ + RetentionDays: tt.retentionDays, + }) + requireSDKError(t, err, http.StatusInternalServerError) + + response, err := adminClient.GetChatRetentionDays(ctx) + require.NoError(t, err) + require.Equal(t, int32(30), response.RetentionDays) + + after, err := rawDB.GetChatSiteConfigValue(ctx, "agents_chat_retention_days") + require.NoError(t, err) + require.Equal(t, before, after) + + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.EqualValues(t, http.StatusInternalServerError, logs[0].StatusCode) + require.JSONEq(t, "{}", string(logs[0].Diff)) + }) + } +} + +func TestChatRetentionDays_AuditConcurrentIdenticalWritesSingleEntry(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + mAudit := audit.NewMock() + adminClient := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) + coderdtest.CreateFirstUser(t, adminClient.Client) + mAudit.ResetLogs() + + const writes = 2 + start := make(chan struct{}) + errs := make(chan error, writes) + var wg sync.WaitGroup + for range writes { + wg.Add(1) + go func() { + defer wg.Done() + <-start + errs <- adminClient.UpdateChatRetentionDays(ctx, codersdk.UpdateChatRetentionDaysRequest{ + RetentionDays: 90, + }) + }() + } + 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.ResourceTypeChatOperationalSettings, logs[0].ResourceType) + require.Equal(t, database.AuditActionWrite, logs[0].Action) +} + func TestChatRetentionDays(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) diff --git a/enterprise/coderd/chat_operational_settings_audit_test.go b/enterprise/coderd/chat_operational_settings_audit_test.go index 4b5d0fa966445..454007703aedf 100644 --- a/enterprise/coderd/chat_operational_settings_audit_test.go +++ b/enterprise/coderd/chat_operational_settings_audit_test.go @@ -3,6 +3,7 @@ package coderd_test import ( "context" "encoding/json" + "strconv" "testing" "time" @@ -30,12 +31,13 @@ type chatOperationalSettingsAuditFixture struct { } type chatOperationalSettingAuditCase struct { - name string - key string - diffField string - oldValue string - newValue string - write func(context.Context, *codersdk.ExperimentalClient) error + name string + key string + diffField string + oldValue string + newValue string + effectiveDefault string + write func(context.Context, *codersdk.ExperimentalClient, string) error } func newChatOperationalSettingsAuditFixture(t *testing.T) chatOperationalSettingsAuditFixture { @@ -98,171 +100,145 @@ func requireChatOperationalSettingsAuditDiff(t *testing.T, log database.AuditLog func TestChatOperationalSettingsAudit(t *testing.T) { t.Parallel() - retentionDays := chatOperationalSettingAuditCase{ - name: "RetentionDays", - key: "agents_chat_retention_days", - diffField: "chat_retention_days", - oldValue: "30", - newValue: "47", - write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { - return client.UpdateChatRetentionDays(ctx, codersdk.UpdateChatRetentionDaysRequest{RetentionDays: 47}) - }, - } - debugRetentionDays := chatOperationalSettingAuditCase{ - name: "DebugRetentionDays", - key: "agents_chat_debug_retention_days", - diffField: "chat_debug_retention_days", - oldValue: "7", - newValue: "47", - write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { - return client.UpdateChatDebugRetentionDays(ctx, codersdk.UpdateChatDebugRetentionDaysRequest{DebugRetentionDays: 47}) - }, - } - autoArchiveDays := chatOperationalSettingAuditCase{ - name: "AutoArchiveDays", - key: "agents_chat_auto_archive_days", - diffField: "chat_auto_archive_days", - oldValue: "14", - newValue: "47", - write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { - return client.UpdateChatAutoArchiveDays(ctx, codersdk.UpdateChatAutoArchiveDaysRequest{AutoArchiveDays: 47}) - }, - } - workspaceTTL := chatOperationalSettingAuditCase{ - name: "WorkspaceTTL", - key: "agents_workspace_ttl", - diffField: "workspace_ttl", - oldValue: "1h0m0s", - newValue: "2h0m0s", - write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { - return client.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ - WorkspaceTTLMillis: (2 * time.Hour).Milliseconds(), - }) - }, - } - computerUseProvider := chatOperationalSettingAuditCase{ - name: "ComputerUseProvider", - key: "agents_computer_use_provider", - diffField: "computer_use_provider", - oldValue: string(codersdk.ChatComputerUseProviderAnthropic), - newValue: string(codersdk.ChatComputerUseProviderOpenAI), - write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { - return client.UpdateChatComputerUseProvider(ctx, codersdk.UpdateChatComputerUseProviderRequest{ - Provider: codersdk.ChatComputerUseProviderOpenAI, - }) - }, - } - debugLogging := chatOperationalSettingAuditCase{ - name: "DebugLogging", - key: "agents_chat_debug_logging_allow_users", - diffField: "debug_logging_allow_users", - oldValue: "false", - newValue: "true", - write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { - return client.UpdateChatDebugLogging(ctx, codersdk.UpdateChatDebugLoggingAllowUsersRequest{AllowUsers: true}) - }, - } - personalModelOverrides := chatOperationalSettingAuditCase{ - name: "PersonalModelOverrides", - key: "agents_chat_personal_model_overrides_enabled", - diffField: "personal_model_overrides_enabled", - oldValue: "false", - newValue: "true", - write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { - return client.UpdateChatPersonalModelOverridesAdminSettings(ctx, codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest{ - AllowUsers: true, - }) + settings := []chatOperationalSettingAuditCase{ + { + name: "RetentionDays", + key: "agents_chat_retention_days", + diffField: "chat_retention_days", + oldValue: "30", + newValue: "47", + effectiveDefault: "30", + write: func(ctx context.Context, client *codersdk.ExperimentalClient, value string) error { + parsed, err := strconv.ParseInt(value, 10, 32) + if err != nil { + return err + } + return client.UpdateChatRetentionDays(ctx, codersdk.UpdateChatRetentionDaysRequest{RetentionDays: int32(parsed)}) + }, }, - } - - typedFields := []chatOperationalSettingAuditCase{ - retentionDays, - debugRetentionDays, - autoArchiveDays, - workspaceTTL, - computerUseProvider, - debugLogging, - personalModelOverrides, - } - for _, setting := range typedFields { - t.Run(setting.name, func(t *testing.T) { - t.Parallel() - - fixture := newChatOperationalSettingsAuditFixture(t) - require.NoError(t, fixture.db.UpsertRuntimeConfig(fixture.systemCtx, database.UpsertRuntimeConfigParams{ - Key: setting.key, - Value: setting.oldValue, - })) - require.NoError(t, setting.write(fixture.ctx, fixture.client)) - - log := fixture.onlyAuditLog(t) - requireChatOperationalSettingsAuditDiff(t, log, setting.diffField, setting.oldValue, setting.newValue) - }) - } - - effectiveDefaults := []struct { - name string - key string - write func(context.Context, *codersdk.ExperimentalClient) error - }{ { - name: "RetentionDaysEffectiveDefault", - key: retentionDays.key, - write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { - return client.UpdateChatRetentionDays(ctx, codersdk.UpdateChatRetentionDaysRequest{RetentionDays: 30}) + name: "DebugRetentionDays", + key: "agents_chat_debug_retention_days", + diffField: "chat_debug_retention_days", + oldValue: "7", + newValue: "47", + effectiveDefault: strconv.FormatInt(int64(codersdk.DefaultChatDebugRetentionDays), 10), + write: func(ctx context.Context, client *codersdk.ExperimentalClient, value string) error { + parsed, err := strconv.ParseInt(value, 10, 32) + if err != nil { + return err + } + return client.UpdateChatDebugRetentionDays(ctx, codersdk.UpdateChatDebugRetentionDaysRequest{DebugRetentionDays: int32(parsed)}) }, }, { - name: "DebugRetentionDaysEffectiveDefault", - key: debugRetentionDays.key, - write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { - return client.UpdateChatDebugRetentionDays(ctx, codersdk.UpdateChatDebugRetentionDaysRequest{ - DebugRetentionDays: codersdk.DefaultChatDebugRetentionDays, - }) + name: "AutoArchiveDays", + key: "agents_chat_auto_archive_days", + diffField: "chat_auto_archive_days", + oldValue: "14", + newValue: "47", + effectiveDefault: strconv.FormatInt(int64(codersdk.DefaultChatAutoArchiveDays), 10), + write: func(ctx context.Context, client *codersdk.ExperimentalClient, value string) error { + parsed, err := strconv.ParseInt(value, 10, 32) + if err != nil { + return err + } + return client.UpdateChatAutoArchiveDays(ctx, codersdk.UpdateChatAutoArchiveDaysRequest{AutoArchiveDays: int32(parsed)}) }, }, { - name: "AutoArchiveDaysEffectiveDefault", - key: autoArchiveDays.key, - write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { - return client.UpdateChatAutoArchiveDays(ctx, codersdk.UpdateChatAutoArchiveDaysRequest{ - AutoArchiveDays: codersdk.DefaultChatAutoArchiveDays, + name: "WorkspaceTTL", + key: "agents_workspace_ttl", + diffField: "workspace_ttl", + oldValue: time.Hour.String(), + newValue: (2 * time.Hour).String(), + effectiveDefault: "0s", + write: func(ctx context.Context, client *codersdk.ExperimentalClient, value string) error { + ttl, err := time.ParseDuration(value) + if err != nil { + return err + } + return client.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ + WorkspaceTTLMillis: ttl.Milliseconds(), }) }, }, { - name: "WorkspaceTTLEffectiveDefault", - key: workspaceTTL.key, - write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { - return client.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{ - WorkspaceTTLMillis: codersdk.DefaultChatWorkspaceTTL, + name: "ComputerUseProvider", + key: "agents_computer_use_provider", + diffField: "computer_use_provider", + oldValue: string(codersdk.ChatComputerUseProviderAnthropic), + newValue: string(codersdk.ChatComputerUseProviderOpenAI), + write: func(ctx context.Context, client *codersdk.ExperimentalClient, value string) error { + return client.UpdateChatComputerUseProvider(ctx, codersdk.UpdateChatComputerUseProviderRequest{ + Provider: codersdk.ChatComputerUseProvider(value), }) }, }, { - name: "DebugLoggingEffectiveDefault", - key: debugLogging.key, - write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { - return client.UpdateChatDebugLogging(ctx, codersdk.UpdateChatDebugLoggingAllowUsersRequest{AllowUsers: false}) + name: "DebugLogging", + key: "agents_chat_debug_logging_allow_users", + diffField: "debug_logging_allow_users", + oldValue: "false", + newValue: "true", + effectiveDefault: "false", + write: func(ctx context.Context, client *codersdk.ExperimentalClient, value string) error { + allowUsers, err := strconv.ParseBool(value) + if err != nil { + return err + } + return client.UpdateChatDebugLogging(ctx, codersdk.UpdateChatDebugLoggingAllowUsersRequest{AllowUsers: allowUsers}) }, }, { - name: "PersonalModelOverridesEffectiveDefault", - key: personalModelOverrides.key, - write: func(ctx context.Context, client *codersdk.ExperimentalClient) error { + name: "PersonalModelOverrides", + key: "agents_chat_personal_model_overrides_enabled", + diffField: "personal_model_overrides_enabled", + oldValue: "false", + newValue: "true", + effectiveDefault: "false", + write: func(ctx context.Context, client *codersdk.ExperimentalClient, value string) error { + allowUsers, err := strconv.ParseBool(value) + if err != nil { + return err + } return client.UpdateChatPersonalModelOverridesAdminSettings(ctx, codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest{ - AllowUsers: false, + AllowUsers: allowUsers, }) }, }, } - for _, setting := range effectiveDefaults { + retentionDays := settings[0] + computerUseProvider := settings[4] + + for _, setting := range settings { t.Run(setting.name, func(t *testing.T) { t.Parallel() + fixture := newChatOperationalSettingsAuditFixture(t) + require.NoError(t, fixture.db.UpsertRuntimeConfig(fixture.systemCtx, database.UpsertRuntimeConfigParams{ + Key: setting.key, + Value: setting.oldValue, + })) + require.NoError(t, setting.write(fixture.ctx, fixture.client, setting.newValue)) + + log := fixture.onlyAuditLog(t) + requireChatOperationalSettingsAuditDiff(t, log, setting.diffField, setting.oldValue, setting.newValue) + }) + + if setting.effectiveDefault == "" { + continue + } + t.Run(setting.name+"EffectiveDefault", func(t *testing.T) { + t.Parallel() + fixture := newChatOperationalSettingsAuditFixture(t) require.NoError(t, fixture.db.DeleteRuntimeConfig(fixture.systemCtx, setting.key)) - require.NoError(t, setting.write(fixture.ctx, fixture.client)) + require.NoError(t, setting.write(fixture.ctx, fixture.client, setting.effectiveDefault)) + + stored, err := fixture.db.GetChatSiteConfigValue(fixture.systemCtx, setting.key) + require.NoError(t, err) + require.Equal(t, database.GetChatSiteConfigValueRow{}, stored) require.Empty(t, fixture.auditLogs(t)) }) } @@ -272,7 +248,7 @@ func TestChatOperationalSettingsAudit(t *testing.T) { fixture := newChatOperationalSettingsAuditFixture(t) require.NoError(t, fixture.db.DeleteRuntimeConfig(fixture.systemCtx, computerUseProvider.key)) - require.NoError(t, computerUseProvider.write(fixture.ctx, fixture.client)) + require.NoError(t, computerUseProvider.write(fixture.ctx, fixture.client, computerUseProvider.newValue)) log := fixture.onlyAuditLog(t) requireChatOperationalSettingsAuditDiff(t, log, computerUseProvider.diffField, "", computerUseProvider.newValue) @@ -286,7 +262,7 @@ func TestChatOperationalSettingsAudit(t *testing.T) { Key: retentionDays.key, Value: retentionDays.oldValue, })) - require.NoError(t, retentionDays.write(fixture.ctx, fixture.client)) + require.NoError(t, retentionDays.write(fixture.ctx, fixture.client, retentionDays.newValue)) log := fixture.onlyAuditLog(t) require.Equal(t, database.AuditActionWrite, log.Action) @@ -297,15 +273,19 @@ func TestChatOperationalSettingsAudit(t *testing.T) { require.EqualValues(t, 204, log.StatusCode) }) - t.Run("IdenticalWrite", func(t *testing.T) { + t.Run("IdenticalStoredValue", func(t *testing.T) { t.Parallel() fixture := newChatOperationalSettingsAuditFixture(t) require.NoError(t, fixture.db.UpsertRuntimeConfig(fixture.systemCtx, database.UpsertRuntimeConfigParams{ - Key: debugLogging.key, - Value: debugLogging.newValue, + Key: retentionDays.key, + Value: retentionDays.newValue, })) - require.NoError(t, debugLogging.write(fixture.ctx, fixture.client)) + require.NoError(t, retentionDays.write(fixture.ctx, fixture.client, retentionDays.newValue)) + + stored, err := fixture.db.GetChatSiteConfigValue(fixture.systemCtx, retentionDays.key) + require.NoError(t, err) + require.Equal(t, database.GetChatSiteConfigValueRow{Value: retentionDays.newValue, Exists: true}, stored) require.Empty(t, fixture.auditLogs(t)) }) @@ -330,7 +310,7 @@ func TestChatOperationalSettingsAudit(t *testing.T) { Key: retentionDays.key, Value: malformed, })) - require.NoError(t, fixture.client.UpdateChatRetentionDays(fixture.ctx, codersdk.UpdateChatRetentionDaysRequest{RetentionDays: 60})) + require.NoError(t, retentionDays.write(fixture.ctx, fixture.client, "60")) log := fixture.onlyAuditLog(t) requireChatOperationalSettingsAuditDiff(t, log, retentionDays.diffField, malformed, "60") diff --git a/site/src/pages/AuditPage/AuditPageView.stories.tsx b/site/src/pages/AuditPage/AuditPageView.stories.tsx index 249f153891999..cba54475f79c1 100644 --- a/site/src/pages/AuditPage/AuditPageView.stories.tsx +++ b/site/src/pages/AuditPage/AuditPageView.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { type ComponentProps, useState } from "react"; +import type { ComponentProps } from "react"; import { expect, fn, screen, userEvent, within } from "storybook/test"; +import type { ResourceType } from "#/api/typesGenerated"; import { getDefaultFilterProps, MockMenu, @@ -61,58 +62,6 @@ export const AuditPage: Story = { }, }; -const AuditPageWithResourceTypeFilter = ( - props: ComponentProps, -) => { - const [resourceType, setResourceType] = useState(); - const resourceTypeMenu = useResourceTypeFilterMenu({ - value: resourceType, - onChange: (option) => setResourceType(option?.value), - }); - - return ( - - ); -}; - -export const ChatOperationalSettingsFilter: Story = { - args: { - auditsQuery: mockSuccessResult, - }, - render: (args) => , - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const resourceTypeFilter = canvas.getByRole("button", { - name: "Select a resource type", - }); - await userEvent.click(resourceTypeFilter); - - const option = await screen.findByRole("option", { - name: "Chat Operational Settings", - }); - await userEvent.click(option); - await expect(resourceTypeFilter).toHaveTextContent( - "Chat Operational Settings", - ); - }, -}; - export const Loading: Story = { args: { auditLogs: undefined, @@ -178,9 +127,12 @@ 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 = { +// Uses the real resource-type menu so generated resource types and their +// friendly labels are verified together. +const resourceTypeFilterStory = ( + label: string, + value: ResourceType, +): Story => ({ args: { auditsQuery: mockSuccessResult, }, @@ -209,19 +161,24 @@ export const FilterByChatInstructionSettings: Story = { await userEvent.click( canvas.getByRole("button", { name: "Select a resource type" }), ); - const option = await screen.findByRole("option", { - name: "Chat Instruction Settings", - }); + const option = await screen.findByRole("option", { name: label }); await userEvent.click(option); await expect(onResourceTypeChange).toHaveBeenCalledWith( - expect.objectContaining({ - value: "chat_instruction_settings", - label: "Chat Instruction Settings", - }), + expect.objectContaining({ value, label }), ); }, -}; +}); + +export const FilterByChatInstructionSettings = resourceTypeFilterStory( + "Chat Instruction Settings", + "chat_instruction_settings", +); + +export const ChatOperationalSettingsFilter = resourceTypeFilterStory( + "Chat Operational Settings", + "chat_operational_settings", +); export const MultiOrg: Story = { parameters: { pixel: { matrix: pixelWithTablet } }, From 5157d5512e6a9f1e643a6983eb1fb24a90a2e684 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 20 Aug 2026 16:00:32 +0000 Subject: [PATCH 4/5] test(enterprise/coderd): consolidate settings audit cases --- .../chat_operational_settings_audit_test.go | 74 +++++-------------- 1 file changed, 18 insertions(+), 56 deletions(-) diff --git a/enterprise/coderd/chat_operational_settings_audit_test.go b/enterprise/coderd/chat_operational_settings_audit_test.go index 454007703aedf..dc40754cbf2de 100644 --- a/enterprise/coderd/chat_operational_settings_audit_test.go +++ b/enterprise/coderd/chat_operational_settings_audit_test.go @@ -35,6 +35,7 @@ type chatOperationalSettingAuditCase struct { key string diffField string oldValue string + oldValueAbsent bool newValue string effectiveDefault string write func(context.Context, *codersdk.ExperimentalClient, string) error @@ -95,6 +96,12 @@ func requireChatOperationalSettingsAuditDiff(t *testing.T, log database.AuditLog require.Equal(t, audit.Map{ field: {Old: oldValue, New: newValue}, }, diff) + require.Equal(t, database.AuditActionWrite, log.Action) + require.Equal(t, database.ResourceTypeChatOperationalSettings, log.ResourceType) + require.Empty(t, log.ResourceTarget) + require.NotEqual(t, uuid.Nil, log.ResourceID) + require.Equal(t, uuid.Nil, log.OrganizationID) + require.EqualValues(t, 204, log.StatusCode) } func TestChatOperationalSettingsAudit(t *testing.T) { @@ -164,11 +171,11 @@ func TestChatOperationalSettingsAudit(t *testing.T) { }, }, { - name: "ComputerUseProvider", - key: "agents_computer_use_provider", - diffField: "computer_use_provider", - oldValue: string(codersdk.ChatComputerUseProviderAnthropic), - newValue: string(codersdk.ChatComputerUseProviderOpenAI), + name: "ComputerUseProvider", + key: "agents_computer_use_provider", + diffField: "computer_use_provider", + oldValueAbsent: true, + newValue: string(codersdk.ChatComputerUseProviderOpenAI), write: func(ctx context.Context, client *codersdk.ExperimentalClient, value string) error { return client.UpdateChatComputerUseProvider(ctx, codersdk.UpdateChatComputerUseProviderRequest{ Provider: codersdk.ChatComputerUseProvider(value), @@ -209,17 +216,18 @@ func TestChatOperationalSettingsAudit(t *testing.T) { }, } retentionDays := settings[0] - computerUseProvider := settings[4] for _, setting := range settings { t.Run(setting.name, func(t *testing.T) { t.Parallel() fixture := newChatOperationalSettingsAuditFixture(t) - require.NoError(t, fixture.db.UpsertRuntimeConfig(fixture.systemCtx, database.UpsertRuntimeConfigParams{ - Key: setting.key, - Value: setting.oldValue, - })) + if !setting.oldValueAbsent { + require.NoError(t, fixture.db.UpsertRuntimeConfig(fixture.systemCtx, database.UpsertRuntimeConfigParams{ + Key: setting.key, + Value: setting.oldValue, + })) + } require.NoError(t, setting.write(fixture.ctx, fixture.client, setting.newValue)) log := fixture.onlyAuditLog(t) @@ -243,52 +251,6 @@ func TestChatOperationalSettingsAudit(t *testing.T) { }) } - t.Run("ComputerUseProviderEmptyEffectiveDefault", func(t *testing.T) { - t.Parallel() - - fixture := newChatOperationalSettingsAuditFixture(t) - require.NoError(t, fixture.db.DeleteRuntimeConfig(fixture.systemCtx, computerUseProvider.key)) - require.NoError(t, computerUseProvider.write(fixture.ctx, fixture.client, computerUseProvider.newValue)) - - log := fixture.onlyAuditLog(t) - requireChatOperationalSettingsAuditDiff(t, log, computerUseProvider.diffField, "", computerUseProvider.newValue) - }) - - t.Run("CommonMetadata", func(t *testing.T) { - t.Parallel() - - fixture := newChatOperationalSettingsAuditFixture(t) - require.NoError(t, fixture.db.UpsertRuntimeConfig(fixture.systemCtx, database.UpsertRuntimeConfigParams{ - Key: retentionDays.key, - Value: retentionDays.oldValue, - })) - require.NoError(t, retentionDays.write(fixture.ctx, fixture.client, retentionDays.newValue)) - - log := fixture.onlyAuditLog(t) - require.Equal(t, database.AuditActionWrite, log.Action) - require.Equal(t, database.ResourceTypeChatOperationalSettings, log.ResourceType) - require.Empty(t, log.ResourceTarget) - require.NotEqual(t, uuid.Nil, log.ResourceID) - require.Equal(t, uuid.Nil, log.OrganizationID) - require.EqualValues(t, 204, log.StatusCode) - }) - - t.Run("IdenticalStoredValue", func(t *testing.T) { - t.Parallel() - - fixture := newChatOperationalSettingsAuditFixture(t) - require.NoError(t, fixture.db.UpsertRuntimeConfig(fixture.systemCtx, database.UpsertRuntimeConfigParams{ - Key: retentionDays.key, - Value: retentionDays.newValue, - })) - require.NoError(t, retentionDays.write(fixture.ctx, fixture.client, retentionDays.newValue)) - - stored, err := fixture.db.GetChatSiteConfigValue(fixture.systemCtx, retentionDays.key) - require.NoError(t, err) - require.Equal(t, database.GetChatSiteConfigValueRow{Value: retentionDays.newValue, Exists: true}, stored) - require.Empty(t, fixture.auditLogs(t)) - }) - t.Run("InvalidWrite", func(t *testing.T) { t.Parallel() From deaa893adb7786b0e51b2a537139d4f151fc1580 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 24 Aug 2026 10:23:53 +0000 Subject: [PATCH 5/5] fix: use effective computer use provider default --- coderd/exp_chats.go | 2 +- .../coderd/chat_operational_settings_audit_test.go | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 00409d333ed8e..7feaa3ccff6eb 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4812,7 +4812,7 @@ func (s chatOperationalSetting) defaultValue() string { case chatOperationalSettingWorkspaceTTL: return "0s" case chatOperationalSettingComputerUseProvider: - return "" + return string(chattool.DefaultComputerUseProvider("")) case chatOperationalSettingDebugLoggingAllowUsers, chatOperationalSettingPersonalModelOverridesEnabled: return "false" diff --git a/enterprise/coderd/chat_operational_settings_audit_test.go b/enterprise/coderd/chat_operational_settings_audit_test.go index dc40754cbf2de..5c6f57f9f6ae9 100644 --- a/enterprise/coderd/chat_operational_settings_audit_test.go +++ b/enterprise/coderd/chat_operational_settings_audit_test.go @@ -171,11 +171,13 @@ func TestChatOperationalSettingsAudit(t *testing.T) { }, }, { - name: "ComputerUseProvider", - key: "agents_computer_use_provider", - diffField: "computer_use_provider", - oldValueAbsent: true, - newValue: string(codersdk.ChatComputerUseProviderOpenAI), + name: "ComputerUseProvider", + key: "agents_computer_use_provider", + diffField: "computer_use_provider", + oldValue: string(codersdk.ChatComputerUseProviderAnthropic), + oldValueAbsent: true, + newValue: string(codersdk.ChatComputerUseProviderOpenAI), + effectiveDefault: string(codersdk.ChatComputerUseProviderAnthropic), write: func(ctx context.Context, client *codersdk.ExperimentalClient, value string) error { return client.UpdateChatComputerUseProvider(ctx, codersdk.UpdateChatComputerUseProviderRequest{ Provider: codersdk.ChatComputerUseProvider(value),