mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: make database.Chat auditable (#24485)
Wire database.Chat into the audit system so chat lifecycle events
(creation, patches, etc.) produce audit log entries.
Part of CODAGT-200.
> 🤖
This commit is contained in:
Generated
+4
-2
@@ -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": {
|
||||
|
||||
Generated
+4
-2
@@ -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": {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("{}")
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+2
-1
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'chat';
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+107
-17
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ We track the following resources:
|
||||
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>true</td></tr><tr><td>chat_spend_limit_micros</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr><tr><td>source</td><td>false</td></tr></tbody></table> |
|
||||
| AuditableOrganizationMember<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>roles</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
| Chat<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>agent_id</td><td>false</td></tr><tr><td>archived</td><td>true</td></tr><tr><td>build_id</td><td>false</td></tr><tr><td>client_type</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>dynamic_tools</td><td>false</td></tr><tr><td>heartbeat_at</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>labels</td><td>true</td></tr><tr><td>last_error</td><td>false</td></tr><tr><td>last_injected_context</td><td>false</td></tr><tr><td>last_model_config_id</td><td>false</td></tr><tr><td>last_read_message_id</td><td>false</td></tr><tr><td>mcp_server_ids</td><td>true</td></tr><tr><td>mode</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>parent_chat_id</td><td>false</td></tr><tr><td>pin_order</td><td>true</td></tr><tr><td>plan_mode</td><td>false</td></tr><tr><td>root_chat_id</td><td>false</td></tr><tr><td>started_at</td><td>false</td></tr><tr><td>status</td><td>false</td></tr><tr><td>title</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>worker_id</td><td>false</td></tr><tr><td>workspace_id</td><td>true</td></tr></tbody></table> |
|
||||
| CustomRole<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>is_system</td><td>false</td></tr><tr><td>member_permissions</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>org_permissions</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>site_permissions</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_permissions</td><td>true</td></tr></tbody></table> |
|
||||
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| GroupSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>auto_create_missing_groups</td><td>true</td></tr><tr><td>field</td><td>true</td></tr><tr><td>legacy_group_name_mapping</td><td>false</td></tr><tr><td>mapping</td><td>true</td></tr><tr><td>regex_filter</td><td>true</td></tr></tbody></table> |
|
||||
|
||||
Generated
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user