diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 81bb7709f9e..fee70ca2cf5 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 0d53012fdbf..d91dab0f5b1 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 6fa6fc7ff4b..b26c0f6a68f 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 03aae5856a7..26b0e2b6ff1 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 32641625617..f31434ed08d 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 5eab2778354..bea464f0b72 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 1cba16aad4a..a3c4d42de19 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 f0630ba28c1..f4476bfe997 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 9bd7833dad7..905b4d00823 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 00000000000..5d93105daa6 --- /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 00000000000..1e7f88dce98 --- /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 a3d1ddaade3..3e1464dc07f 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 7e32ec91fce..867d068445f 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 aab0595e533..32d5bf24697 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 0ba38bb148d..ac731d6fd5b 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 ef499baf6d2..52f28e7c5f1 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 185b300b836..85c84d4cf10 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 52923b6c027..7feaa3ccff6 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4787,9 +4787,129 @@ func (api *API) getChatPersonalModelOverridesAdminSettings(rw http.ResponseWrite }) } +const chatOperationalSettingsLockTimeout = 5 * time.Second + +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 string(chattool.DefaultComputerUseProvider("")) + 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 +} + +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], + commitAudit func(bool), + setting chatOperationalSetting, + newValue string, + 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 + } + oldValue := old.Value + 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, aReq.New.ID) + return nil + }, nil) + if err != nil { + 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() + aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r) + defer commitAudit(true) + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { httpapi.Forbidden(rw) return @@ -4799,7 +4919,12 @@ func (api *API) putChatPersonalModelOverridesAdminSettings(rw http.ResponseWrite 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(), @@ -5055,6 +5180,9 @@ 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 @@ -5076,7 +5204,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(), @@ -5117,6 +5250,9 @@ 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 @@ -5126,7 +5262,12 @@ func (api *API) putChatDebugLogging(rw http.ResponseWriter, r *http.Request) { 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(), @@ -5341,6 +5482,9 @@ 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 @@ -5377,8 +5521,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 { @@ -5431,10 +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 } + var req codersdk.UpdateChatRetentionDaysRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5445,7 +5597,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(), @@ -5482,10 +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 } + var req codersdk.UpdateChatDebugRetentionDaysRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5496,7 +5657,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(), @@ -5534,10 +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 } + var req codersdk.UpdateChatAutoArchiveDaysRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -5548,7 +5718,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 cd1b1f457e0..5e4c0499d4e 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -633,6 +633,95 @@ func (s *chatModelConfigHookStore) GetChatModelConfigByID( return s.Store.GetChatModelConfigByID(ctx, id) } +type failNextGetChatSiteConfigValueStore struct { + database.Store + + 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, + 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, @@ -16818,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/codersdk/audit.go b/codersdk/audit.go index d95dc681ab3..83ac629bccc 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 519da9dae9d..214cc2cb0de 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 a757b639485..fbc535c4863 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 6eaecba4b76..a0963914d1e 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 00000000000..5c6f57f9f6a --- /dev/null +++ b/enterprise/coderd/chat_operational_settings_audit_test.go @@ -0,0 +1,282 @@ +package coderd_test + +import ( + "context" + "encoding/json" + "strconv" + "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 + oldValueAbsent bool + newValue string + effectiveDefault string + write func(context.Context, *codersdk.ExperimentalClient, string) 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) + 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) { + t.Parallel() + + 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)}) + }, + }, + { + 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: "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: "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: "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), + }) + }, + }, + { + 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: "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: allowUsers, + }) + }, + }, + } + retentionDays := settings[0] + + for _, setting := range settings { + t.Run(setting.name, func(t *testing.T) { + t.Parallel() + + fixture := newChatOperationalSettingsAuditFixture(t) + 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) + 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, 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)) + }) + } + + 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) + + log := fixture.onlyAuditLog(t) + require.EqualValues(t, 400, log.StatusCode) + require.JSONEq(t, "{}", string(log.Diff)) + }) + + 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, retentionDays.write(fixture.ctx, fixture.client, "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 9e73794b016..837dd91aed2 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 fa527dccc4c..edf178c5578 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 030ed84c0c5..cba54475f79 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 } from "react"; import { expect, fn, screen, userEvent, within } from "storybook/test"; +import type { ResourceType } from "#/api/typesGenerated"; import { getDefaultFilterProps, MockMenu, @@ -126,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, }, @@ -157,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 } },