From c968a1f3a305371825b910505c6d19075dc80403 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 21 Apr 2026 11:11:56 +0100 Subject: [PATCH] feat: make database.Chat auditable (#24485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire database.Chat into the audit system so chat lifecycle events (creation, patches, etc.) produce audit log entries. Part of CODAGT-200. > 🤖 --- coderd/apidoc/docs.go | 6 +- coderd/apidoc/swagger.json | 6 +- coderd/audit.go | 14 ++ coderd/audit/diff.go | 3 +- coderd/audit/fields.go | 2 +- coderd/audit/request.go | 10 ++ coderd/audit_internal_test.go | 41 ++++++ coderd/database/dump.sql | 3 +- .../000472_chat_resource_type_audit.down.sql | 3 + .../000472_chat_resource_type_audit.up.sql | 1 + coderd/database/models.go | 5 +- coderd/exp_chats.go | 39 ++++++ coderd/exp_chats_test.go | 124 +++++++++++++++--- codersdk/audit.go | 3 + docs/admin/security/audit-logs.md | 1 + docs/reference/api/schemas.md | 6 +- enterprise/audit/table.go | 30 +++++ site/src/api/typesGenerated.ts | 2 + 18 files changed, 271 insertions(+), 28 deletions(-) create mode 100644 coderd/database/migrations/000472_chat_resource_type_audit.down.sql create mode 100644 coderd/database/migrations/000472_chat_resource_type_audit.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 95bff0ecf5..9d927e03a5 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -19757,7 +19757,8 @@ const docTemplate = `{ "workspace_agent", "workspace_app", "task", - "ai_seat" + "ai_seat", + "chat" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -19786,7 +19787,8 @@ const docTemplate = `{ "ResourceTypeWorkspaceAgent", "ResourceTypeWorkspaceApp", "ResourceTypeTask", - "ResourceTypeAISeat" + "ResourceTypeAISeat", + "ResourceTypeChat" ] }, "codersdk.Response": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ecb1e47eb4..5c0e1af16a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18089,7 +18089,8 @@ "workspace_agent", "workspace_app", "task", - "ai_seat" + "ai_seat", + "chat" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -18118,7 +18119,8 @@ "ResourceTypeWorkspaceAgent", "ResourceTypeWorkspaceApp", "ResourceTypeTask", - "ResourceTypeAISeat" + "ResourceTypeAISeat", + "ResourceTypeChat" ] }, "codersdk.Response": { diff --git a/coderd/audit.go b/coderd/audit.go index 3d8aed3005..b63c0c0eff 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -435,6 +435,16 @@ func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.Get api.Logger.Error(ctx, "unable to fetch task", slog.Error(err)) } return task.DeletedAt.Valid && task.DeletedAt.Time.Before(time.Now()) + case database.ResourceTypeChat: + // Chats are hard-deleted, so a 404 means deleted. + _, err := api.Database.GetChatByID(ctx, alog.AuditLog.ResourceID) + if xerrors.Is(err, sql.ErrNoRows) { + return true + } + if err != nil { + api.Logger.Error(ctx, "unable to fetch chat", slog.Error(err)) + } + return false default: return false } @@ -522,6 +532,10 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit } return fmt.Sprintf("/tasks/%s/%s", user.Username, task.ID) + case database.ResourceTypeChat: + // Chats are surfaced at /agents/{id}. They are owner-scoped but + // not username-scoped in the URL like workspaces or tasks. + return fmt.Sprintf("/agents/%s", alog.AuditLog.ResourceID) default: return "" } diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index e085c7d9ea..a2c609d91d 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -33,7 +33,8 @@ type Auditable interface { idpsync.GroupSyncSettings | idpsync.RoleSyncSettings | database.TaskTable | - database.AiSeatState + database.AiSeatState | + database.Chat } // Map is a map of changed fields in an audited resource. It maps field names to diff --git a/coderd/audit/fields.go b/coderd/audit/fields.go index a9944767c2..cd6eaeb7ce 100644 --- a/coderd/audit/fields.go +++ b/coderd/audit/fields.go @@ -25,7 +25,7 @@ func BackgroundTaskFieldsBytes(ctx context.Context, logger slog.Logger, subsyste wriBytes, err := json.Marshal(af) if err != nil { - logger.Error(ctx, "marshal additional fields for dormancy audit", slog.Error(err)) + logger.Error(ctx, "marshal additional fields for background audit", slog.Error(err)) return []byte("{}") } diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 147e53e4f7..c73bedcd7d 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -134,6 +134,8 @@ func ResourceTarget[T Auditable](tgt T) string { return typed.Name case database.AiSeatState: return "AI Seat" + case database.Chat: + return typed.Title default: panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt)) } @@ -200,6 +202,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { return typed.ID case database.AiSeatState: return typed.UserID + case database.Chat: + return typed.ID default: panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt)) } @@ -257,6 +261,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeTask case database.AiSeatState: return database.ResourceTypeAiSeat + case database.Chat: + return database.ResourceTypeChat default: panic(fmt.Sprintf("unknown resource %T for ResourceType", typed)) } @@ -317,6 +323,10 @@ func ResourceRequiresOrgID[T Auditable]() bool { return true case database.AiSeatState: return false + case database.Chat: + // Chats always have a non-null organization_id (since + // migration 000467). + return true default: panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt)) } diff --git a/coderd/audit_internal_test.go b/coderd/audit_internal_test.go index f3d3b160d6..cc7fddf3e0 100644 --- a/coderd/audit_internal_test.go +++ b/coderd/audit_internal_test.go @@ -1,13 +1,54 @@ package coderd import ( + "context" + "database/sql" "testing" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbmock" ) +func TestAuditLogIsResourceDeleted(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + err error + wantDeleted bool + }{ + {name: "AnError", err: assert.AnError, wantDeleted: false}, + {name: "NotAuthorized", err: dbauthz.NotAuthorizedError{}, wantDeleted: false}, + {name: "NoError", err: nil, wantDeleted: false}, + {name: "NoRows", err: sql.ErrNoRows, wantDeleted: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{}, tc.err) + + api := &API{ + Options: &Options{Database: db, Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})}, + } + + deleted := api.auditLogIsResourceDeleted(context.Background(), database.GetAuditLogsOffsetRow{ + AuditLog: database.AuditLog{ResourceType: database.ResourceTypeChat, ResourceID: chatID}, + }) + require.Equal(t, tc.wantDeleted, deleted) + }) + } +} + func TestAuditLogDescription(t *testing.T) { t.Parallel() testCases := []struct { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 761dd2de80..88e1cac439 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -525,7 +525,8 @@ CREATE TYPE resource_type AS ENUM ( 'workspace_app', 'prebuilds_settings', 'task', - 'ai_seat' + 'ai_seat', + 'chat' ); CREATE TYPE shareable_workspace_owners AS ENUM ( diff --git a/coderd/database/migrations/000472_chat_resource_type_audit.down.sql b/coderd/database/migrations/000472_chat_resource_type_audit.down.sql new file mode 100644 index 0000000000..e72f1886be --- /dev/null +++ b/coderd/database/migrations/000472_chat_resource_type_audit.down.sql @@ -0,0 +1,3 @@ +-- Postgres does not support removing enum values, so down is a +-- no-op. Rolling back past this migration is not reversible at +-- the schema level. diff --git a/coderd/database/migrations/000472_chat_resource_type_audit.up.sql b/coderd/database/migrations/000472_chat_resource_type_audit.up.sql new file mode 100644 index 0000000000..31a80036c3 --- /dev/null +++ b/coderd/database/migrations/000472_chat_resource_type_audit.up.sql @@ -0,0 +1 @@ +ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'chat'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 298138de60..257c1c5c2a 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3205,6 +3205,7 @@ const ( ResourceTypePrebuildsSettings ResourceType = "prebuilds_settings" ResourceTypeTask ResourceType = "task" ResourceTypeAiSeat ResourceType = "ai_seat" + ResourceTypeChat ResourceType = "chat" ) func (e *ResourceType) Scan(src interface{}) error { @@ -3270,7 +3271,8 @@ func (e ResourceType) Valid() bool { ResourceTypeWorkspaceApp, ResourceTypePrebuildsSettings, ResourceTypeTask, - ResourceTypeAiSeat: + ResourceTypeAiSeat, + ResourceTypeChat: return true } return false @@ -3305,6 +3307,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypePrebuildsSettings, ResourceTypeTask, ResourceTypeAiSeat, + ResourceTypeChat, } } diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index b4f8633a65..a4205851db 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -538,6 +538,15 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { return } + aReq, commitAudit := audit.InitRequest[database.Chat](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), + Log: api.Logger, + Request: r, + Action: database.AuditActionCreate, + OrganizationID: req.OrganizationID, + }) + defer commitAudit() + // Validate organization membership. if req.OrganizationID == uuid.Nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -720,6 +729,8 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { MCPServerIDs: mcpServerIDs, Labels: labels, DynamicTools: dynamicToolsJSON, + // IMPORTANT: users can only create root chats at the time of writing. + ParentChatID: uuid.NullUUID{}, }) if err != nil { if maybeWriteLimitErr(ctx, rw, err) { @@ -747,6 +758,16 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { return } + aReq.New = chat + + if chat.ParentChatID.Valid { + // Should not be possible. If we get here, something is very wrong. Bail. + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Developer error: ParentChatID got set somehow in api.postChats. This should never happen.", + }) + return + } + // Link any user-uploaded files referenced in the initial // message to this newly created chat (best-effort; cap // enforced in SQL). @@ -762,6 +783,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { }) return } + aReq.New = chat chatFiles := api.fetchChatFileMetadata(ctx, chat.ID) response := db2sdk.Chat(chat, nil, chatFiles) @@ -2040,6 +2062,16 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() chat := httpmw.ChatParam(r) + aReq, commitAudit := audit.InitRequest[database.Chat](rw, &audit.RequestParams{ + Audit: *api.Auditor.Load(), + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + defer commitAudit() + aReq.Old = chat + aReq.UpdateOrganizationID(chat.OrganizationID) + var req codersdk.UpdateChatRequest if !httpapi.Read(ctx, rw, r, &req) { return @@ -2263,6 +2295,13 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) { chat = updatedChat } + if refreshed, err := api.Database.GetChatByID(ctx, chat.ID); err == nil { + aReq.New = refreshed + } else { + aReq.New = chat // fallback + api.Logger.Error(ctx, "failed to refresh chat for audit", slog.F("chat_id", chat.ID), slog.Error(err)) + } + rw.WriteHeader(http.StatusNoContent) } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 2aaecb0bcc..2693c5b43c 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -23,6 +23,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd" + "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/db2sdk" @@ -55,12 +56,16 @@ func chatDeploymentValues(t testing.TB) *codersdk.DeploymentValues { return values } -func newChatClient(t testing.TB) *codersdk.ExperimentalClient { +func newChatClient(t testing.TB, overrides ...func(*coderdtest.Options)) *codersdk.ExperimentalClient { t.Helper() - client := coderdtest.New(t, &coderdtest.Options{ + opts := &coderdtest.Options{ DeploymentValues: chatDeploymentValues(t), - }) + } + for _, override := range overrides { + override(opts) + } + client := coderdtest.New(t, opts) return codersdk.NewExperimentalClient(client) } @@ -76,12 +81,16 @@ func newChatClientWithDeploymentValues( return codersdk.NewExperimentalClient(client) } -func newChatClientWithDatabase(t testing.TB) (*codersdk.ExperimentalClient, database.Store) { +func newChatClientWithDatabase(t testing.TB, overrides ...func(*coderdtest.Options)) (*codersdk.ExperimentalClient, database.Store) { t.Helper() - client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + opts := &coderdtest.Options{ DeploymentValues: chatDeploymentValues(t), - }) + } + for _, override := range overrides { + override(opts) + } + client, db := coderdtest.NewWithDatabase(t, opts) return codersdk.NewExperimentalClient(client), db } @@ -210,7 +219,10 @@ func TestPostChats(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client := newChatClient(t) + mAudit := audit.NewMock() + client := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) @@ -259,6 +271,12 @@ func TestPostChats(t *testing.T) { } } require.True(t, foundUserMessage) + require.True(t, mAudit.Contains(t, database.AuditLog{ + Action: database.AuditActionCreate, + ResourceType: database.ResourceTypeChat, + ResourceID: chat.ID, + UserID: member.ID, + })) }) t.Run("MemberWithoutAgentsAccess", func(t *testing.T) { @@ -3961,7 +3979,10 @@ func TestPatchChat(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client := newChatClient(t) + mAudit := audit.NewMock() + client := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModelConfig(t, client) @@ -3973,13 +3994,22 @@ func TestPatchChat(t *testing.T) { updated := getChat(ctx, t, client, chat.ID) require.Equal(t, codersdk.ChatPlanModePlan, updated.PlanMode) + require.True(t, mAudit.Contains(t, database.AuditLog{ + Action: database.AuditActionWrite, + ResourceType: database.ResourceTypeChat, + ResourceID: chat.ID, + UserID: firstUser.UserID, + })) }) t.Run("Clear", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client := newChatClient(t) + mAudit := audit.NewMock() + client := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModelConfig(t, client) @@ -3996,23 +4026,37 @@ func TestPatchChat(t *testing.T) { updated := getChat(ctx, t, client, chat.ID) require.Empty(t, updated.PlanMode) + require.True(t, mAudit.Contains(t, database.AuditLog{ + Action: database.AuditActionWrite, + ResourceType: database.ResourceTypeChat, + ResourceID: chat.ID, + UserID: firstUser.UserID, + })) }) t.Run("RejectsInvalidValue", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client := newChatClient(t) + mAudit := audit.NewMock() + client := newChatClient(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModelConfig(t, client) chat := createChat(ctx, t, client, firstUser.OrganizationID, "invalid plan mode") - invalidPlanMode := codersdk.ChatPlanMode("invalid") err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ - PlanMode: &invalidPlanMode, + PlanMode: ptr.Ref(codersdk.ChatPlanMode("invalid")), }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid plan_mode value.", sdkErr.Message) + require.True(t, mAudit.Contains(t, database.AuditLog{ + Action: database.AuditActionWrite, + ResourceType: database.ResourceTypeChat, + ResourceID: chat.ID, + UserID: firstUser.UserID, + })) }) }) @@ -4023,7 +4067,10 @@ func TestPatchChat(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) + mAudit := audit.NewMock() + client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) @@ -4049,13 +4096,22 @@ func TestPatchChat(t *testing.T) { updated := getChat(ctx, t, client, chat.ID) require.NotNil(t, updated.WorkspaceID) require.Equal(t, workspaceBuild.Workspace.ID, *updated.WorkspaceID) + require.True(t, mAudit.Contains(t, database.AuditLog{ + Action: database.AuditActionWrite, + ResourceType: database.ResourceTypeChat, + ResourceID: chat.ID, + UserID: firstUser.UserID, + })) }) t.Run("WorkspaceNotFound", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) + mAudit := audit.NewMock() + client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) @@ -4074,13 +4130,22 @@ func TestPatchChat(t *testing.T) { }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Workspace not found or you do not have access to this resource", sdkErr.Message) + require.True(t, mAudit.Contains(t, database.AuditLog{ + Action: database.AuditActionWrite, + ResourceType: database.ResourceTypeChat, + ResourceID: chat.ID, + UserID: firstUser.UserID, + })) }) t.Run("RejectsCrossOrgWorkspaceBinding", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) + mAudit := audit.NewMock() + client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) @@ -4108,13 +4173,22 @@ func TestPatchChat(t *testing.T) { }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Workspace does not belong to this chat's organization.", sdkErr.Message) + require.True(t, mAudit.Contains(t, database.AuditLog{ + Action: database.AuditActionWrite, + ResourceType: database.ResourceTypeChat, + ResourceID: chat.ID, + UserID: firstUser.UserID, + })) }) t.Run("ClearWorkspaceBinding", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) + mAudit := audit.NewMock() + client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) { + opts.Auditor = mAudit + }) firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) @@ -4147,6 +4221,12 @@ func TestPatchChat(t *testing.T) { require.Nil(t, updated.WorkspaceID) require.Nil(t, updated.BuildID) require.Nil(t, updated.AgentID) + require.True(t, mAudit.Contains(t, database.AuditLog{ + Action: database.AuditActionWrite, + ResourceType: database.ResourceTypeChat, + ResourceID: chat.ID, + UserID: firstUser.UserID, + })) }) }) @@ -4423,7 +4503,10 @@ func TestArchiveChat(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client := newChatClient(t) + mAudit := audit.NewMock() + client := newChatClient(t, func(o *coderdtest.Options) { + o.Auditor = mAudit + }) firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModelConfig(t, client) @@ -4479,6 +4562,13 @@ func TestArchiveChat(t *testing.T) { require.Len(t, archivedChats, 1) require.Equal(t, chatToArchive.ID, archivedChats[0].ID) require.True(t, archivedChats[0].Archived) + + require.True(t, mAudit.Contains(t, database.AuditLog{ + Action: database.AuditActionWrite, + ResourceType: database.ResourceTypeChat, + ResourceID: chatToArchive.ID, + UserID: firstUser.UserID, + })) }) t.Run("NotFound", func(t *testing.T) { t.Parallel() diff --git a/codersdk/audit.go b/codersdk/audit.go index ac0b4e908f..d8dc880589 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -46,6 +46,7 @@ const ( ResourceTypeWorkspaceApp ResourceType = "workspace_app" ResourceTypeTask ResourceType = "task" ResourceTypeAISeat ResourceType = "ai_seat" + ResourceTypeChat ResourceType = "chat" ) func (r ResourceType) FriendlyString() string { @@ -106,6 +107,8 @@ func (r ResourceType) FriendlyString() string { return "task" case ResourceTypeAISeat: return "ai seat" + case ResourceTypeChat: + return "chat" default: return "unknown" } diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 9337d84623..4876e0fde4 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -20,6 +20,7 @@ We track the following resources: | AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| | Group
create, write, delete | |
FieldTracked
avatar_urltrue
chat_spend_limit_microstrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| | AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| +| Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
created_atfalse
dynamic_toolsfalse
heartbeat_atfalse
idtrue
labelstrue
last_errorfalse
last_injected_contextfalse
last_model_config_idfalse
last_read_message_idfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
parent_chat_idfalse
pin_ordertrue
plan_modefalse
root_chat_idfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
worker_idfalse
workspace_idtrue
| | 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
public_keytrue
updated_atfalse
user_idtrue
| | GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index fd3fef6764..716e93e01e 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -8487,9 +8487,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| #### Enumerated Values -| Value(s) | -|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_seat`, `api_key`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | +| Value(s) | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_seat`, `api_key`, `chat`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | ## codersdk.Response diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index fbf7fe1a47..e43bccae7a 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -29,6 +29,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{ "License": {codersdk.AuditActionCreate, codersdk.AuditActionDelete}, "Task": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "AiSeatState": {codersdk.AuditActionCreate}, + "Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted. } type Action string @@ -378,6 +379,35 @@ var auditableResourcesTypes = map[any]map[string]Action{ "created_at": ActionIgnore, // Never changes. "deleted_at": ActionIgnore, // Changes, but is implicit when a delete event is fired. }, + &database.Chat{}: { + "id": ActionTrack, + "owner_id": ActionTrack, + "organization_id": ActionIgnore, // Never changes after creation. + "workspace_id": ActionTrack, + "build_id": ActionIgnore, // Internal lifecycle. + "agent_id": ActionIgnore, // Internal lifecycle. + "title": ActionTrack, + "status": ActionIgnore, // Churns every message. + "worker_id": ActionIgnore, // Internal. + "started_at": ActionIgnore, + "heartbeat_at": ActionIgnore, + "created_at": ActionIgnore, // Never changes. + "updated_at": ActionIgnore, // Bumped on every mutation. + "parent_chat_id": ActionIgnore, // Immutable after creation. + "root_chat_id": ActionIgnore, // Immutable after creation. + "last_model_config_id": ActionIgnore, // Churns every message. + "archived": ActionTrack, + "last_error": ActionIgnore, + "mode": ActionTrack, + "mcp_server_ids": ActionTrack, + "labels": ActionTrack, + "pin_order": ActionTrack, + "last_read_message_id": ActionIgnore, // User-scoped read cursor. + "last_injected_context": ActionIgnore, // Internal lifecycle. + "dynamic_tools": ActionIgnore, // Internal lifecycle. + "plan_mode": ActionIgnore, // Can flip back and forth during a session. + "client_type": ActionIgnore, // Set at creation. + }, } // auditMap converts a map of struct pointers to a map of struct names as diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a1ed0d3ea9..a012981818 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6236,6 +6236,7 @@ export interface ResolveAutostartResponse { export type ResourceType = | "ai_seat" | "api_key" + | "chat" | "convert_login" | "custom_role" | "git_ssh_key" @@ -6265,6 +6266,7 @@ export type ResourceType = export const ResourceTypes: ResourceType[] = [ "ai_seat", "api_key", + "chat", "convert_login", "custom_role", "git_ssh_key",