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 |
| Field | Tracked |
| | created_at | false |
| group_id | true |
| group_name | true |
| spend_limit | true |
| spend_limit_micros | false |
| updated_at | false |
| user_id | false |
| username | false |
|
| 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
write | | 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 |
|
+| 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 |
|
| 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/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},