From df3931bd98e9905e2ea18401ff074b9110aaf90c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 21 Aug 2026 18:12:39 +0000 Subject: [PATCH] feat(coderd): audit chat model config changes --- coderd/audit/request.go | 2 +- coderd/exp_chats.go | 174 ++++++++++++++++++---- coderd/exp_chats_model_audit_test.go | 206 +++++++++++++++++++++++++++ docs/admin/security/audit-logs.md | 2 +- enterprise/audit/table.go | 2 +- 5 files changed, 357 insertions(+), 29 deletions(-) create mode 100644 coderd/exp_chats_model_audit_test.go diff --git a/coderd/audit/request.go b/coderd/audit/request.go index e83b5d7fb2c..03aae5856a7 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -156,7 +156,7 @@ func ResourceTarget[T Auditable](tgt T) string { // filter but not the primary resource identifier. return typed.ID.String()[:8] case database.ChatModelConfig: - return cmp.Or(typed.DisplayName, typed.Model, typed.ID.String()) + return cmp.Or(typed.DisplayName, typed.ID.String()) case database.MCPServerConfig: // Updates can persist an empty display name; fall back to the slug, or // the ID if both are empty, so the audit entry stays identifiable. diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 45c33b5af1d..95d09720d1e 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -7195,6 +7195,41 @@ func (api *API) inChatModelConfigWriteTx( }, &database.TxOptions{Isolation: sql.LevelReadCommitted}) } +type chatModelConfigAuditTransition struct { + Old database.ChatModelConfig + New database.ChatModelConfig +} + +func (api *API) auditChatModelConfigTransitions( + ctx context.Context, + r *http.Request, + userID uuid.UUID, + status int, + transitions []chatModelConfigAuditTransition, +) { + if len(transitions) == 0 { + return + } + auditor := api.Auditor.Load() + auditCtx := context.WithoutCancel(ctx) + requestID := httpmw.RequestID(r) + for _, transition := range transitions { + audit.BackgroundAudit(auditCtx, &audit.BackgroundAuditParams[database.ChatModelConfig]{ + Audit: *auditor, + Log: api.Logger, + UserID: userID, + RequestID: requestID, + Status: status, + IP: r.RemoteAddr, + UserAgent: r.UserAgent(), + Action: database.AuditActionWrite, + OrganizationID: transition.New.OrganizationID, + Old: transition.Old, + New: transition.New, + }) + } +} + // @Summary Create an AI model in an organization // @ID create-ai-model // @Security CoderSessionToken @@ -7211,6 +7246,16 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) organization := httpmw.OrganizationParam(r) + auditor := api.Auditor.Load() + aReq, commitAudit := audit.InitRequest[database.ChatModelConfig](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionCreate, + OrganizationID: organization.ID, + }) + defer commitAudit() + var req struct { codersdk.CreateChatModelRequest GroupACL json.RawMessage `json:"group_acl"` @@ -7302,9 +7347,12 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { UserACL: database.ChatACL{}, } - var inserted database.ChatModelConfig + var ( + inserted database.ChatModelConfig + auditTransitions []chatModelConfigAuditTransition + ) err := api.inChatModelConfigWriteTx(ctx, insertParams.OrganizationID, func(tx database.Store) error { - _, err := tx.GetDefaultChatModelConfig(ctx, insertParams.OrganizationID) + currentDefault, err := tx.GetDefaultChatModelConfig(ctx, insertParams.OrganizationID) defaultExists := err == nil if err != nil && !xerrors.Is(err, sql.ErrNoRows) { return xerrors.Errorf("get default model config: %w", err) @@ -7315,6 +7363,12 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { if err := tx.UnsetDefaultChatModelConfigs(ctx, insertParams.OrganizationID); err != nil { return xerrors.Errorf("unset default model configs: %w", err) } + //nolint:gocritic // The create write owns authorization for this transition. + demoted, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), currentDefault.ID) + if err != nil { + return xerrors.Errorf("refresh demoted default chat model config: %w", err) + } + auditTransitions = append(auditTransitions, chatModelConfigAuditTransition{Old: currentDefault, New: demoted}) } insertParams.IsDefault = insertAsDefault @@ -7342,9 +7396,13 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { return err } - if err := ensureDefaultChatModelConfig(ctx, tx, insertParams.OrganizationID); err != nil { + transition, err := ensureDefaultChatModelConfig(ctx, tx, insertParams.OrganizationID) + if err != nil { return err } + if transition != nil { + auditTransitions = append(auditTransitions, *transition) + } refreshedConfig, err := tx.GetChatModelConfigByID(ctx, inserted.ID) if err != nil { @@ -7382,6 +7440,8 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { } } + aReq.New = inserted + api.auditChatModelConfigTransitions(ctx, r, apiKey.UserID, http.StatusCreated, auditTransitions) publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, inserted.ID) httpapi.Write(ctx, rw, http.StatusCreated, convertChatModelConfig(inserted)) @@ -7408,6 +7468,17 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { return } + auditor := api.Auditor.Load() + aReq, commitAudit := audit.InitRequest[database.ChatModelConfig](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + OrganizationID: existing.OrganizationID, + }) + defer commitAudit() + aReq.Old = existing + var req struct { codersdk.UpdateChatModelRequest GroupACL json.RawMessage `json:"group_acl"` @@ -7457,7 +7528,10 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { requestedModelConfig = encodedModelConfig } - var updated database.ChatModelConfig + var ( + updated database.ChatModelConfig + auditTransitions []chatModelConfigAuditTransition + ) err := api.inChatModelConfigWriteTx(ctx, existing.OrganizationID, func(tx database.Store) error { // The middleware lookup above only rejects unknown IDs; a concurrent // writer can change or delete the row between the two reads, so merge @@ -7471,6 +7545,8 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("get chat model config for update: %w", err) } + aReq.Old = lockedExisting + model := lockedExisting.Model if trimmed := strings.TrimSpace(req.Model); trimmed != "" { model = trimmed @@ -7539,12 +7615,25 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { setAsDefault := updateParams.IsDefault && !lockedExisting.IsDefault if setAsDefault { + //nolint:gocritic // The target update owns authorization for the sibling transition. + currentDefault, err := tx.GetDefaultChatModelConfig(dbauthz.AsSystemRestricted(ctx), lockedExisting.OrganizationID) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("get default model config before update: %w", err) + } if err := tx.UnsetDefaultChatModelConfigs(ctx, lockedExisting.OrganizationID); err != nil { return xerrors.Errorf("unset default model configs: %w", err) } + if err == nil { + //nolint:gocritic // The target update owns authorization for the sibling transition. + demoted, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), currentDefault.ID) + if err != nil { + return xerrors.Errorf("refresh demoted default chat model config: %w", err) + } + auditTransitions = append(auditTransitions, chatModelConfigAuditTransition{Old: currentDefault, New: demoted}) + } } - _, err = tx.UpdateChatModelConfig(ctx, updateParams) + updated, err = tx.UpdateChatModelConfig(ctx, updateParams) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { return errChatModelConfigNotFound @@ -7557,21 +7646,22 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { excludeConfigID = lockedExisting.ID } - if err := ensureDefaultChatModelConfig( + transition, err := ensureDefaultChatModelConfig( ctx, tx, lockedExisting.OrganizationID, excludeConfigID, - ); err != nil { + ) + if err != nil { return err } - - //nolint:gocritic // The update above reauthorized the locked row. - refreshedConfig, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), lockedExisting.ID) - if err != nil { - return xerrors.Errorf("refresh updated chat model config: %w", err) + if transition != nil { + if transition.New.ID == lockedExisting.ID { + updated = transition.New + } else { + auditTransitions = append(auditTransitions, *transition) + } } - updated = refreshedConfig return nil }) if err != nil { @@ -7606,6 +7696,8 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { } } + aReq.New = updated + api.auditChatModelConfigTransitions(ctx, r, apiKey.UserID, http.StatusOK, auditTransitions) publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, updated.ID) httpapi.Write(ctx, rw, http.StatusOK, convertChatModelConfig(updated)) @@ -7628,14 +7720,42 @@ func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) { return } + auditor := api.Auditor.Load() + aReq, commitAudit := audit.InitRequest[database.ChatModelConfig](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionDelete, + OrganizationID: existing.OrganizationID, + }) + defer commitAudit() + aReq.Old = existing + + var auditTransitions []chatModelConfigAuditTransition if err := api.inChatModelConfigWriteTx(ctx, existing.OrganizationID, func(tx database.Store) error { - if _, err := tx.DeleteChatModelConfigByID(ctx, existing.ID); err != nil { + //nolint:gocritic // The delete write below reauthorizes the locked row. + current, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), existing.ID) + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + return errChatModelConfigNotFound + } + return xerrors.Errorf("get chat model config for delete: %w", err) + } + aReq.Old = current + if _, err := tx.DeleteChatModelConfigByID(ctx, current.ID); err != nil { if xerrors.Is(err, sql.ErrNoRows) { return errChatModelConfigNotFound } return err } - return ensureDefaultChatModelConfig(ctx, tx, existing.OrganizationID) + transition, err := ensureDefaultChatModelConfig(ctx, tx, current.OrganizationID) + if err != nil { + return err + } + if transition != nil { + auditTransitions = append(auditTransitions, *transition) + } + return nil }); err != nil { if dbauthz.IsNotAuthorizedError(err) { // The dbauthz object delete check is the write access boundary; @@ -7654,6 +7774,7 @@ func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) { return } + api.auditChatModelConfigTransitions(ctx, r, httpmw.APIKey(r).UserID, http.StatusNoContent, auditTransitions) publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, existing.ID) rw.WriteHeader(http.StatusNoContent) @@ -7666,22 +7787,22 @@ func ensureDefaultChatModelConfig( tx database.Store, organizationID uuid.UUID, excludedConfigIDs ...uuid.UUID, -) error { +) (*chatModelConfigAuditTransition, error) { //nolint:gocritic // Default election is contained by action-authorized writes. _, err := tx.GetDefaultChatModelConfig(dbauthz.AsSystemRestricted(ctx), organizationID) switch { case err == nil: - return nil + return nil, nil //nolint:nilnil // A nil transition means no default changed. case !xerrors.Is(err, sql.ErrNoRows): - return xerrors.Errorf("get default model config: %w", err) + return nil, xerrors.Errorf("get default model config: %w", err) } orgModelConfigs, err := tx.GetChatModelConfigsByOrganization(ctx, organizationID) if err != nil { - return xerrors.Errorf("list default chat model config candidates: %w", err) + return nil, xerrors.Errorf("list default chat model config candidates: %w", err) } if len(orgModelConfigs) == 0 { - return nil + return nil, nil //nolint:nilnil // No model remains to promote. } // Prefer a config that can actually serve requests (enabled, under an @@ -7690,7 +7811,7 @@ func ensureDefaultChatModelConfig( // when no usable candidate exists. enabledRows, err := tx.GetEnabledChatModelConfigsByOrganization(ctx, organizationID) if err != nil { - return xerrors.Errorf("list enabled chat model configs: %w", err) + return nil, xerrors.Errorf("list enabled chat model configs: %w", err) } usable := make(map[uuid.UUID]struct{}, len(enabledRows)) for _, row := range enabledRows { @@ -7726,20 +7847,21 @@ func ensureDefaultChatModelConfig( candidateConfig := *selected if err := tx.UnsetDefaultChatModelConfigs(ctx, organizationID); err != nil { - return xerrors.Errorf("unset default model configs: %w", err) + return nil, xerrors.Errorf("unset default model configs: %w", err) } params := chatModelConfigToUpdateParams(candidateConfig) params.IsDefault = true - if _, err := tx.UpdateChatModelConfig(ctx, params); err != nil { + promoted, err := tx.UpdateChatModelConfig(ctx, params) + if err != nil { if xerrors.Is(err, sql.ErrNoRows) { // Do not wrap with %w. Callers map target misses to 404, but a // default-candidate race is an internal retryable failure. - return xerrors.Errorf("set default model config: %v", err) + return nil, xerrors.Errorf("set default model config: %v", err) } - return xerrors.Errorf("set default model config: %w", err) + return nil, xerrors.Errorf("set default model config: %w", err) } - return nil + return &chatModelConfigAuditTransition{Old: candidateConfig, New: promoted}, nil } func chatModelConfigToUpdateParams( diff --git a/coderd/exp_chats_model_audit_test.go b/coderd/exp_chats_model_audit_test.go new file mode 100644 index 00000000000..f5caec4986d --- /dev/null +++ b/coderd/exp_chats_model_audit_test.go @@ -0,0 +1,206 @@ +package coderd_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "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/dbtestutil" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +//nolint:tparallel,paralleltest // Subtests share one audit fixture at a time and run sequentially. +func TestChatModelConfigAudit(t *testing.T) { + t.Parallel() + + newFixture := func(t *testing.T) (context.Context, *codersdk.ExperimentalClient, *audit.MockAuditor, codersdk.CreateFirstUserResponse, codersdk.AIProvider) { + t.Helper() + ctx := testutil.Context(t, testutil.WaitLong) + mAudit := audit.NewMockWithDiffFn(func(old, newVal any) audit.Map { + oldConfig, oldOK := old.(database.ChatModelConfig) + newConfig, newOK := newVal.(database.ChatModelConfig) + if !oldOK || !newOK { + return audit.Map{} + } + return audit.Map{ + "is_default": {Old: oldConfig.IsDefault, New: newConfig.IsDefault}, + } + }) + client := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + provider := createAIProviderForTest(t, client, "openai", "test-api-key") + mAudit.ResetLogs() + return ctx, client, mAudit, firstUser, provider + } + + createModel := func(t *testing.T, ctx context.Context, client *codersdk.ExperimentalClient, organizationID, providerID uuid.UUID, displayName string, isDefault bool) codersdk.ChatModel { + t.Helper() + contextLimit := int64(4096) + model, err := client.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{ + AIProviderID: &providerID, + Model: "audit-model-" + uuid.NewString(), + DisplayName: displayName, + ContextLimit: &contextLimit, + IsDefault: &isDefault, + }) + require.NoError(t, err) + return model + } + + findLog := func(t *testing.T, logs []database.AuditLog, id uuid.UUID, action database.AuditAction) database.AuditLog { + t.Helper() + for _, log := range logs { + if log.ResourceID == id && log.Action == action { + return log + } + } + t.Fatalf("audit log not found for %s %s", id, action) + return database.AuditLog{} + } + + isDefaultDiff := func(t *testing.T, log database.AuditLog, oldValue, newValue bool) { + t.Helper() + var diff map[string]codersdk.AuditDiffField + require.NoError(t, json.Unmarshal(log.Diff, &diff)) + require.Equal(t, codersdk.AuditDiffField{Old: oldValue, New: newValue}, diff["is_default"]) + } + + t.Run("Create", func(t *testing.T) { + ctx, client, mAudit, firstUser, provider := newFixture(t) + model := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "Audited Model", false) + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + log := findLog(t, logs, model.ID, database.AuditActionCreate) + require.Equal(t, database.ResourceTypeChatModelConfig, log.ResourceType) + require.Equal(t, "Audited Model", log.ResourceTarget) + require.Equal(t, firstUser.UserID, log.UserID) + require.Equal(t, firstUser.OrganizationID, log.OrganizationID) + require.EqualValues(t, http.StatusCreated, log.StatusCode) + }) + + t.Run("CreateTargetFallbackUsesID", func(t *testing.T) { + ctx, client, mAudit, firstUser, provider := newFixture(t) + model := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "", false) + log := findLog(t, mAudit.AuditLogs(), model.ID, database.AuditActionCreate) + require.Equal(t, model.ID.String(), log.ResourceTarget) + require.NotEqual(t, model.Model, log.ResourceTarget) + }) + + t.Run("CreateDefaultDemotesSibling", func(t *testing.T) { + ctx, client, mAudit, firstUser, provider := newFixture(t) + first := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "First", false) + mAudit.ResetLogs() + second := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "Second", true) + logs := mAudit.AuditLogs() + require.Len(t, logs, 2) + isDefaultDiff(t, findLog(t, logs, first.ID, database.AuditActionWrite), true, false) + findLog(t, logs, second.ID, database.AuditActionCreate) + }) + + t.Run("UpdatePromoteDemotesSibling", func(t *testing.T) { + ctx, client, mAudit, firstUser, provider := newFixture(t) + first := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "First", false) + second := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "Second", false) + mAudit.ResetLogs() + _, err := client.UpdateChatModel(ctx, firstUser.OrganizationID, second.ID, codersdk.UpdateChatModelRequest{IsDefault: ptr.Ref(true)}) + require.NoError(t, err) + logs := mAudit.AuditLogs() + require.Len(t, logs, 2) + isDefaultDiff(t, findLog(t, logs, second.ID, database.AuditActionWrite), false, true) + isDefaultDiff(t, findLog(t, logs, first.ID, database.AuditActionWrite), true, false) + }) + + t.Run("UpdateDemotePromotesSibling", func(t *testing.T) { + ctx, client, mAudit, firstUser, provider := newFixture(t) + first := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "First", false) + second := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "Second", false) + mAudit.ResetLogs() + _, err := client.UpdateChatModel(ctx, firstUser.OrganizationID, first.ID, codersdk.UpdateChatModelRequest{IsDefault: ptr.Ref(false)}) + require.NoError(t, err) + logs := mAudit.AuditLogs() + require.Len(t, logs, 2) + isDefaultDiff(t, findLog(t, logs, first.ID, database.AuditActionWrite), true, false) + isDefaultDiff(t, findLog(t, logs, second.ID, database.AuditActionWrite), false, true) + }) + + t.Run("UpdateSoleDemoteRemainsDefault", func(t *testing.T) { + ctx, client, mAudit, firstUser, provider := newFixture(t) + model := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "Only", false) + mAudit.ResetLogs() + updated, err := client.UpdateChatModel(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelRequest{IsDefault: ptr.Ref(false)}) + require.NoError(t, err) + require.True(t, updated.IsDefault) + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + isDefaultDiff(t, findLog(t, logs, model.ID, database.AuditActionWrite), true, true) + }) + + t.Run("DeleteNonDefault", func(t *testing.T) { + ctx, client, mAudit, firstUser, provider := newFixture(t) + _ = createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "First", false) + second := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "Second", false) + mAudit.ResetLogs() + require.NoError(t, client.DeleteChatModel(ctx, firstUser.OrganizationID, second.ID)) + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + findLog(t, logs, second.ID, database.AuditActionDelete) + }) + + t.Run("DeleteDefaultPromotesSibling", func(t *testing.T) { + ctx, client, mAudit, firstUser, provider := newFixture(t) + first := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "First", false) + second := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "Second", false) + mAudit.ResetLogs() + require.NoError(t, client.DeleteChatModel(ctx, firstUser.OrganizationID, first.ID)) + logs := mAudit.AuditLogs() + require.Len(t, logs, 2) + findLog(t, logs, first.ID, database.AuditActionDelete) + isDefaultDiff(t, findLog(t, logs, second.ID, database.AuditActionWrite), false, true) + }) + + t.Run("DeniedUpdateHasNoAudit", func(t *testing.T) { + ctx, client, mAudit, firstUser, provider := newFixture(t) + model := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "Denied", false) + memberRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) + member := codersdk.NewExperimentalClient(memberRaw) + mAudit.ResetLogs() + _, err := member.UpdateChatModel(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelRequest{DisplayName: "Nope"}) + requireSDKError(t, err, http.StatusNotFound) + require.Empty(t, mAudit.AuditLogs()) + }) + + t.Run("RollbackHasNoSiblingAudit", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + mAudit := audit.NewMock() + rawDB, pubsub := dbtestutil.NewDB(t) + store := newChatModelConfigHookStore(rawDB) + client := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + opts.Database = store + opts.Pubsub = pubsub + }) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + provider := createAIProviderForTest(t, client, "openai", "test-api-key") + first := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "First", false) + second := createModel(t, ctx, client, firstUser.OrganizationID, provider.ID, "Second", false) + mAudit.ResetLogs() + store.armFailNextUpdate(second.ID) + _, err := client.UpdateChatModel(ctx, firstUser.OrganizationID, second.ID, codersdk.UpdateChatModelRequest{IsDefault: ptr.Ref(true)}) + requireSDKError(t, err, http.StatusNotFound) + for _, log := range mAudit.AuditLogs() { + require.NotEqual(t, first.ID, log.ResourceID) + require.NotEqualValues(t, http.StatusOK, log.StatusCode) + } + }) +} diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index d51b4bde77d..519da9dae9d 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -27,7 +27,7 @@ We track the following resources: | AuditableUserAIBudgetOverride
write, delete | |
FieldTracked
created_atfalse
group_idtrue
group_nametrue
spend_limittrue
spend_limit_microsfalse
updated_atfalse
user_idfalse
usernamefalse
| | Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
compaction_requested_atfalse
context_aggregate_hashfalse
context_dirty_resourcesfalse
context_dirty_sincefalse
context_errorfalse
created_atfalse
dynamic_toolsfalse
generation_attemptfalse
group_acltrue
heartbeat_atfalse
history_versionfalse
idtrue
labelstrue
last_errorfalse
last_model_config_idfalse
last_read_message_idfalse
last_reasoning_effortfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
queue_versionfalse
requires_action_deadline_atfalse
retry_statefalse
retry_state_versionfalse
root_chat_idfalse
runner_idfalse
snapshot_versionfalse
started_atfalse
statusfalse
summaryfalse
summary_generated_atfalse
titletrue
updated_atfalse
user_acltrue
worker_idfalse
workspace_idtrue
| | ChatInstructionSettings
write | |
FieldTracked
idfalse
include_default_system_prompttrue
include_default_system_prompt_settrue
namefalse
plan_mode_instructionstrue
system_prompttrue
| -| ChatModelConfig
write | |
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
| +| 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
| | CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| | GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
private_key_key_idfalse
public_keytrue
updated_atfalse
user_idtrue
| | GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 3d881231add..6eaecba4b76 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -35,7 +35,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{ "AuditableGroupAIBudget": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "AuditableUserAIBudgetOverride": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted. - "ChatModelConfig": {codersdk.AuditActionWrite}, + "ChatModelConfig": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "MCPServerConfig": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},