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 |
| Field | Tracked |
| | agent_id | false |
| archived | true |
| build_id | false |
| client_type | false |
| compaction_requested_at | false |
| context_aggregate_hash | false |
| context_dirty_resources | false |
| context_dirty_since | false |
| context_error | false |
| created_at | false |
| dynamic_tools | false |
| generation_attempt | false |
| group_acl | true |
| heartbeat_at | false |
| history_version | false |
| id | true |
| labels | true |
| last_error | false |
| last_model_config_id | false |
| last_read_message_id | false |
| last_reasoning_effort | false |
| last_turn_summary | false |
| mcp_server_ids | true |
| mode | true |
| organization_id | false |
| owner_id | true |
| owner_name | false |
| owner_username | false |
| parent_chat_id | false |
| pin_order | true |
| plan_mode | false |
| queue_version | false |
| requires_action_deadline_at | false |
| retry_state | false |
| retry_state_version | false |
| root_chat_id | false |
| runner_id | false |
| snapshot_version | false |
| started_at | false |
| status | false |
| summary | false |
| summary_generated_at | false |
| title | true |
| updated_at | false |
| user_acl | true |
| worker_id | false |
| workspace_id | true |
|
| ChatInstructionSettings
write | | Field | Tracked |
| | id | false |
| include_default_system_prompt | true |
| include_default_system_prompt_set | true |
| name | false |
| plan_mode_instructions | true |
| system_prompt | true |
|
| ChatModelConfig
create, write, delete | | Field | Tracked |
| | ai_provider_id | true |
| compression_threshold | true |
| context_limit | true |
| created_at | false |
| created_by | true |
| deleted | true |
| deleted_at | false |
| display_name | true |
| enabled | true |
| group_acl | true |
| id | false |
| is_default | true |
| model | true |
| options | true |
| organization_id | false |
| updated_at | false |
| updated_by | true |
| user_acl | true |
|
+| ChatOperationalSettings
write | | Field | Tracked |
| | chat_auto_archive_days | true |
| chat_debug_retention_days | true |
| chat_retention_days | true |
| computer_use_provider | true |
| debug_logging_allow_users | true |
| id | false |
| personal_model_overrides_enabled | true |
| workspace_ttl | true |
|
| CustomRole
| | Field | Tracked |
| | created_at | false |
| display_name | true |
| id | false |
| is_system | false |
| member_permissions | true |
| name | true |
| org_permissions | true |
| organization_id | false |
| site_permissions | true |
| updated_at | false |
| user_permissions | true |
|
| GitSSHKey
create | | Field | Tracked |
| | created_at | false |
| private_key | true |
| private_key_key_id | false |
| public_key | true |
| updated_at | false |
| user_id | true |
|
| GroupSyncSettings
| | Field | Tracked |
| | auto_create_missing_groups | true |
| field | true |
| legacy_group_name_mapping | false |
| mapping | true |
| regex_filter | true |
|
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 } },