feat: add chat sharing foundation (#25041)

This commit is contained in:
Danielle Maywood
2026-05-18 22:32:05 +01:00
committed by GitHub
parent 2732378da2
commit 170a6e1fe9
49 changed files with 1872 additions and 103 deletions
+5
View File
@@ -14668,6 +14668,7 @@ const docTemplate = `{
"chat:create",
"chat:delete",
"chat:read",
"chat:share",
"chat:update",
"coder:all",
"coder:apikeys.manage_self",
@@ -14888,6 +14889,7 @@ const docTemplate = `{
"APIKeyScopeChatCreate",
"APIKeyScopeChatDelete",
"APIKeyScopeChatRead",
"APIKeyScopeChatShare",
"APIKeyScopeChatUpdate",
"APIKeyScopeCoderAll",
"APIKeyScopeCoderApikeysManageSelf",
@@ -17879,6 +17881,9 @@ const docTemplate = `{
"derp": {
"$ref": "#/definitions/codersdk.DERP"
},
"disable_chat_sharing": {
"type": "boolean"
},
"disable_owner_workspace_exec": {
"type": "boolean"
},
+5
View File
@@ -13120,6 +13120,7 @@
"chat:create",
"chat:delete",
"chat:read",
"chat:share",
"chat:update",
"coder:all",
"coder:apikeys.manage_self",
@@ -13340,6 +13341,7 @@
"APIKeyScopeChatCreate",
"APIKeyScopeChatDelete",
"APIKeyScopeChatRead",
"APIKeyScopeChatShare",
"APIKeyScopeChatUpdate",
"APIKeyScopeCoderAll",
"APIKeyScopeCoderApikeysManageSelf",
@@ -16219,6 +16221,9 @@
"derp": {
"$ref": "#/definitions/codersdk.DERP"
},
"disable_chat_sharing": {
"type": "boolean"
},
"disable_owner_workspace_exec": {
"type": "boolean"
},
+5 -1
View File
@@ -343,16 +343,20 @@ func New(options *Options) *API {
panic("developer error: options.PrometheusRegistry is nil and not running a unit test")
}
if options.DeploymentValues.DisableOwnerWorkspaceExec || options.DeploymentValues.DisableWorkspaceSharing {
if options.DeploymentValues.DisableOwnerWorkspaceExec || options.DeploymentValues.DisableWorkspaceSharing || options.DeploymentValues.DisableChatSharing {
rbac.ReloadBuiltinRoles(&rbac.RoleOptions{
NoOwnerWorkspaceExec: bool(options.DeploymentValues.DisableOwnerWorkspaceExec),
NoWorkspaceSharing: bool(options.DeploymentValues.DisableWorkspaceSharing),
NoChatSharing: bool(options.DeploymentValues.DisableChatSharing),
})
}
if options.DeploymentValues.DisableWorkspaceSharing {
rbac.SetWorkspaceACLDisabled(true)
}
if options.DeploymentValues.DisableChatSharing {
rbac.SetChatACLDisabled(true)
}
if options.PrometheusRegistry == nil {
options.PrometheusRegistry = prometheus.NewRegistry()
+3
View File
@@ -19,6 +19,9 @@ const (
CheckChatUsageLimitConfigDefaultLimitMicrosCheck CheckConstraint = "chat_usage_limit_config_default_limit_micros_check" // chat_usage_limit_config
CheckChatUsageLimitConfigPeriodCheck CheckConstraint = "chat_usage_limit_config_period_check" // chat_usage_limit_config
CheckChatUsageLimitConfigSingletonCheck CheckConstraint = "chat_usage_limit_config_singleton_check" // chat_usage_limit_config
CheckChatAclOnlyOnRootChats CheckConstraint = "chat_acl_only_on_root_chats" // chats
CheckChatGroupAclNotNullJsonb CheckConstraint = "chat_group_acl_not_null_jsonb" // chats
CheckChatUserAclNotNullJsonb CheckConstraint = "chat_user_acl_not_null_jsonb" // chats
CheckChatsPinOrderArchivedCheck CheckConstraint = "chats_pin_order_archived_check" // chats
CheckChatsPinOrderParentCheck CheckConstraint = "chats_pin_order_parent_check" // chats
CheckOneTimePasscodeSet CheckConstraint = "one_time_passcode_set" // users
+71 -3
View File
@@ -2682,6 +2682,17 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI
return q.db.GetAuthorizationUserRoles(ctx, userID)
}
func (q *querier) GetChatACLByID(ctx context.Context, id uuid.UUID) (database.GetChatACLByIDRow, error) {
chat, err := q.db.GetChatByID(ctx, id)
if err != nil {
return database.GetChatACLByIDRow{}, err
}
if err := q.authorizeContext(ctx, policy.ActionRead, chat); err != nil {
return database.GetChatACLByIDRow{}, err
}
return q.db.GetChatACLByID(ctx, id)
}
func (q *querier) GetChatAdvisorConfig(ctx context.Context) (string, error) {
// The advisor configuration is a deployment-wide setting read by any
// authenticated chat user and by chatd when deciding whether to attach
@@ -2884,14 +2895,30 @@ func (q *querier) GetChatFileByID(ctx context.Context, id uuid.UUID) (database.C
if err != nil {
return database.ChatFile{}, err
}
if err := q.authorizeContext(ctx, policy.ActionRead, file); err != nil {
fileAuthErr := q.authorizeContext(ctx, policy.ActionRead, file)
if fileAuthErr == nil {
return file, nil
}
prepared, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceChat.Type)
if err != nil {
return database.ChatFile{}, xerrors.Errorf("(dev error) prepare sql filter: %w", err)
}
chats, err := q.db.GetAuthorizedChatsByChatFileID(ctx, id, prepared)
if err != nil {
return database.ChatFile{}, err
}
if len(chats) == 0 {
return database.ChatFile{}, fileAuthErr
}
return file, nil
}
func (q *querier) GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]database.GetChatFileMetadataByChatIDRow, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatFileMetadataByChatID)(ctx, chatID)
if _, err := q.GetChatByID(ctx, chatID); err != nil {
return nil, err
}
return q.db.GetChatFileMetadataByChatID(ctx, chatID)
}
func (q *querier) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]database.ChatFile, error) {
@@ -2899,10 +2926,25 @@ func (q *querier) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]dat
if err != nil {
return nil, err
}
var prepared rbac.PreparedAuthorized
for _, f := range files {
if err := q.authorizeContext(ctx, policy.ActionRead, f); err != nil {
fileAuthErr := q.authorizeContext(ctx, policy.ActionRead, f)
if fileAuthErr == nil {
continue
}
if prepared == nil {
prepared, err = prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceChat.Type)
if err != nil {
return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err)
}
}
chats, err := q.db.GetAuthorizedChatsByChatFileID(ctx, f.ID, prepared)
if err != nil {
return nil, err
}
if len(chats) == 0 {
return nil, fileAuthErr
}
}
return files, nil
}
@@ -3164,6 +3206,10 @@ func (q *querier) GetChats(ctx context.Context, arg database.GetChatsParams) ([]
return q.db.GetAuthorizedChats(ctx, arg, prep)
}
func (q *querier) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID) ([]database.Chat, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatsByChatFileID)(ctx, fileID)
}
func (q *querier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.Chat, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatsByWorkspaceIDs)(ctx, ids)
}
@@ -6392,6 +6438,24 @@ func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKe
return update(q.log, q.auth, fetch, q.db.UpdateAPIKeyByID)(ctx, arg)
}
func (q *querier) UpdateChatACLByID(ctx context.Context, arg database.UpdateChatACLByIDParams) error {
if rbac.ChatACLDisabled() {
return NotAuthorizedError{Err: xerrors.New("chat sharing is disabled")}
}
fetch := func(ctx context.Context, arg database.UpdateChatACLByIDParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return database.Chat{}, err
}
if chat.IsSubChat() {
return database.Chat{}, NotAuthorizedError{Err: xerrors.New("chat ACLs can only be updated on root chats")}
}
return chat, nil
}
return fetchAndExec(q.log, q.auth, policy.ActionShare, fetch, q.db.UpdateChatACLByID)(ctx, arg)
}
func (q *querier) UpdateChatBuildAgentBinding(ctx context.Context, arg database.UpdateChatBuildAgentBindingParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
@@ -8323,3 +8387,7 @@ func (q *querier) ListAuthorizedAIBridgeSessionThreads(ctx context.Context, arg
func (q *querier) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, _ rbac.PreparedAuthorized) ([]database.GetChatsRow, error) {
return q.GetChats(ctx, arg)
}
func (q *querier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.Chat, error) {
return q.db.GetAuthorizedChatsByChatFileID(ctx, fileID, prepared)
}
+149 -2
View File
@@ -155,6 +155,108 @@ func TestNew(t *testing.T) {
require.NoError(t, rec.AllAsserted(), "should only be 1 rbac call")
}
func TestChatFilesAllowLinkedChatReads(t *testing.T) {
t.Parallel()
ctx := dbauthz.As(context.Background(), rbac.Subject{
ID: uuid.NewString(),
Scope: rbac.ScopeAll,
})
authorizer := &coderdtest.FakeAuthorizer{
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
if action == policy.ActionRead && object.Type == rbac.ResourceChat.Type {
return xerrors.New("direct file auth denied")
}
return nil
},
}
t.Run("GetChatFileByID", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
file := testutil.Fake(t, gofakeit.New(0), database.ChatFile{})
db.EXPECT().Wrappers().Return([]string{}).AnyTimes()
db.EXPECT().GetChatFileByID(gomock.Any(), file.ID).Return(file, nil)
db.EXPECT().GetAuthorizedChatsByChatFileID(gomock.Any(), file.ID, gomock.Any()).Return([]database.Chat{{ID: uuid.New()}}, nil)
q := dbauthz.New(db, authorizer, slogtest.Make(t, nil), coderdtest.AccessControlStorePointer())
got, err := q.GetChatFileByID(ctx, file.ID)
require.NoError(t, err)
require.Equal(t, file, got)
})
t.Run("GetChatFilesByIDs", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
file := testutil.Fake(t, gofakeit.New(0), database.ChatFile{})
db.EXPECT().Wrappers().Return([]string{}).AnyTimes()
db.EXPECT().GetChatFilesByIDs(gomock.Any(), []uuid.UUID{file.ID}).Return([]database.ChatFile{file}, nil)
db.EXPECT().GetAuthorizedChatsByChatFileID(gomock.Any(), file.ID, gomock.Any()).Return([]database.Chat{{ID: uuid.New()}}, nil)
q := dbauthz.New(db, authorizer, slogtest.Make(t, nil), coderdtest.AccessControlStorePointer())
got, err := q.GetChatFilesByIDs(ctx, []uuid.UUID{file.ID})
require.NoError(t, err)
require.Equal(t, []database.ChatFile{file}, got)
})
}
//nolint:tparallel,paralleltest // It toggles the global chat ACL flag.
func TestUpdateChatACLByIDGuards(t *testing.T) {
ctx := dbauthz.As(context.Background(), rbac.Subject{
ID: uuid.NewString(),
Scope: rbac.ScopeAll,
})
arg := database.UpdateChatACLByIDParams{
ID: uuid.New(),
UserACL: database.ChatACL{},
GroupACL: database.ChatACL{},
}
t.Run("Disabled", func(t *testing.T) { //nolint:paralleltest // It toggles the global chat ACL flag.
rbac.SetChatACLDisabled(true)
t.Cleanup(func() { rbac.SetChatACLDisabled(false) })
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
db.EXPECT().Wrappers().Return([]string{}).AnyTimes()
q := dbauthz.New(db, &coderdtest.FakeAuthorizer{}, slogtest.Make(t, nil), coderdtest.AccessControlStorePointer())
err := q.UpdateChatACLByID(ctx, arg)
require.Error(t, err)
require.True(t, dbauthz.IsNotAuthorizedError(err))
require.ErrorContains(t, err, "chat sharing is disabled")
})
t.Run("SubChat", func(t *testing.T) { //nolint:paralleltest // It depends on the global chat ACL flag.
rbac.SetChatACLDisabled(false)
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
db.EXPECT().Wrappers().Return([]string{}).AnyTimes()
db.EXPECT().GetChatByID(gomock.Any(), arg.ID).Return(database.Chat{
ID: arg.ID,
RootChatID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
ParentChatID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
}, nil)
q := dbauthz.New(db, &coderdtest.FakeAuthorizer{}, slogtest.Make(t, nil), coderdtest.AccessControlStorePointer())
err := q.UpdateChatACLByID(ctx, arg)
require.Error(t, err)
require.True(t, dbauthz.IsNotAuthorizedError(err))
require.ErrorContains(t, err, "root chats")
})
}
// TestDBAuthzRecursive is a simple test to search for infinite recursion
// bugs. It isn't perfect, and only catches a subset of the possible bugs
// as only the first db call will be made. But it is better than nothing.
@@ -585,6 +687,20 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpsertChatPersonalModelOverridesEnabled(gomock.Any(), true).Return(nil).AnyTimes()
check.Args(true).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
}))
s.Run("GetChatACLByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
row := database.GetChatACLByIDRow{
Users: database.ChatACL{
uuid.NewString(): database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}},
},
Groups: database.ChatACL{
uuid.NewString(): database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}},
},
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().GetChatACLByID(gomock.Any(), chat.ID).Return(row, nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(row)
}))
s.Run("GetChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
@@ -722,14 +838,17 @@ func (s *MethodTestSuite) TestChats() {
s.Run("GetChatFileByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
file := testutil.Fake(s.T(), faker, database.ChatFile{})
dbm.EXPECT().GetChatFileByID(gomock.Any(), file.ID).Return(file, nil).AnyTimes()
dbm.EXPECT().GetAuthorizedChatsByChatFileID(gomock.Any(), file.ID, gomock.Any()).Return([]database.Chat{}, nil).AnyTimes()
check.Args(file.ID).Asserts(rbac.ResourceChat.WithOwner(file.OwnerID.String()).InOrg(file.OrganizationID).WithID(file.ID), policy.ActionRead).Returns(file)
}))
s.Run("GetChatFilesByIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
file := testutil.Fake(s.T(), faker, database.ChatFile{})
dbm.EXPECT().GetChatFilesByIDs(gomock.Any(), []uuid.UUID{file.ID}).Return([]database.ChatFile{file}, nil).AnyTimes()
dbm.EXPECT().GetAuthorizedChatsByChatFileID(gomock.Any(), file.ID, gomock.Any()).Return([]database.Chat{}, nil).AnyTimes()
check.Args([]uuid.UUID{file.ID}).Asserts(rbac.ResourceChat.WithOwner(file.OwnerID.String()).InOrg(file.OrganizationID).WithID(file.ID), policy.ActionRead).Returns([]database.ChatFile{file})
}))
s.Run("GetChatFileMetadataByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
file := testutil.Fake(s.T(), faker, database.ChatFile{})
rows := []database.GetChatFileMetadataByChatIDRow{{
ID: file.ID,
@@ -739,8 +858,9 @@ func (s *MethodTestSuite) TestChats() {
OwnerID: file.OwnerID,
OrganizationID: file.OrganizationID,
}}
dbm.EXPECT().GetChatFileMetadataByChatID(gomock.Any(), file.ID).Return(rows, nil).AnyTimes()
check.Args(file.ID).Asserts(rbac.ResourceChat.WithOwner(file.OwnerID.String()).InOrg(file.OrganizationID).WithID(file.ID), policy.ActionRead).Returns(rows)
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().GetChatFileMetadataByChatID(gomock.Any(), chat.ID).Return(rows, nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(rows)
}))
s.Run("DeleteOldChatDebugRuns", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().DeleteOldChatDebugRuns(gomock.Any(), database.DeleteOldChatDebugRunsParams{}).Return(int64(0), nil).AnyTimes()
@@ -886,6 +1006,14 @@ func (s *MethodTestSuite) TestChats() {
// No asserts here because SQLFilter.
check.Args(params).Asserts()
}))
s.Run("GetChatsByChatFileID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chatA := testutil.Fake(s.T(), faker, database.Chat{})
chatB := testutil.Fake(s.T(), faker, database.Chat{})
fileID := uuid.New()
chats := []database.Chat{chatA, chatB}
dbm.EXPECT().GetChatsByChatFileID(gomock.Any(), fileID).Return(chats, nil).AnyTimes()
check.Args(fileID).Asserts(chatA, policy.ActionRead, chatB, policy.ActionRead).Returns(chats)
}))
s.Run("GetChildChatsByParentIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
parentA := testutil.Fake(s.T(), faker, database.Chat{})
parentB := testutil.Fake(s.T(), faker, database.Chat{})
@@ -913,6 +1041,12 @@ func (s *MethodTestSuite) TestChats() {
// No asserts here because it re-routes through GetChats which uses SQLFilter.
check.Args(params, emptyPreparedAuthorized{}).Asserts()
}))
s.Run("GetAuthorizedChatsByChatFileID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
fileID := uuid.New()
dbm.EXPECT().GetAuthorizedChatsByChatFileID(gomock.Any(), fileID, gomock.Any()).Return([]database.Chat{}, nil).AnyTimes()
// No asserts here because callers provide the SQL filter.
check.Args(fileID, emptyPreparedAuthorized{}).Asserts()
}))
s.Run("GetChatQueuedMessages", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
qms := []database.ChatQueuedMessage{testutil.Fake(s.T(), faker, database.ChatQueuedMessage{})}
@@ -1057,6 +1191,19 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().ReorderChatQueuedMessageToFront(gomock.Any(), arg).Return(int64(1), nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1))
}))
s.Run("UpdateChatACLByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
chat.RootChatID = uuid.NullUUID{}
chat.ParentChatID = uuid.NullUUID{}
arg := database.UpdateChatACLByIDParams{
ID: chat.ID,
UserACL: database.ChatACL{},
GroupACL: database.ChatACL{},
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatACLByID(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionShare).Returns()
}))
s.Run("UpdateChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatByIDParams{
+32
View File
@@ -1209,6 +1209,14 @@ func (m queryMetricsStore) GetAuthorizationUserRoles(ctx context.Context, userID
return r0, r1
}
func (m queryMetricsStore) GetChatACLByID(ctx context.Context, id uuid.UUID) (database.GetChatACLByIDRow, error) {
start := time.Now()
r0, r1 := m.s.GetChatACLByID(ctx, id)
m.queryLatencies.WithLabelValues("GetChatACLByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatACLByID").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatAdvisorConfig(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetChatAdvisorConfig(ctx)
@@ -1625,6 +1633,14 @@ func (m queryMetricsStore) GetChats(ctx context.Context, arg database.GetChatsPa
return r0, r1
}
func (m queryMetricsStore) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID) ([]database.Chat, error) {
start := time.Now()
r0, r1 := m.s.GetChatsByChatFileID(ctx, fileID)
m.queryLatencies.WithLabelValues("GetChatsByChatFileID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatsByChatFileID").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.Chat, error) {
start := time.Now()
r0, r1 := m.s.GetChatsByWorkspaceIDs(ctx, ids)
@@ -4641,6 +4657,14 @@ func (m queryMetricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.Up
return r0
}
func (m queryMetricsStore) UpdateChatACLByID(ctx context.Context, arg database.UpdateChatACLByIDParams) error {
start := time.Now()
r0 := m.s.UpdateChatACLByID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatACLByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatACLByID").Inc()
return r0
}
func (m queryMetricsStore) UpdateChatBuildAgentBinding(ctx context.Context, arg database.UpdateChatBuildAgentBindingParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatBuildAgentBinding(ctx, arg)
@@ -6160,3 +6184,11 @@ func (m queryMetricsStore) GetAuthorizedChats(ctx context.Context, arg database.
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAuthorizedChats").Inc()
return r0, r1
}
func (m queryMetricsStore) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.Chat, error) {
start := time.Now()
r0, r1 := m.s.GetAuthorizedChatsByChatFileID(ctx, fileID, prepared)
m.queryLatencies.WithLabelValues("GetAuthorizedChatsByChatFileID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAuthorizedChatsByChatFileID").Inc()
return r0, r1
}
+59
View File
@@ -2146,6 +2146,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedChats(ctx, arg, prepared any) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedChats", reflect.TypeOf((*MockStore)(nil).GetAuthorizedChats), ctx, arg, prepared)
}
// GetAuthorizedChatsByChatFileID mocks base method.
func (m *MockStore) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAuthorizedChatsByChatFileID", ctx, fileID, prepared)
ret0, _ := ret[0].([]database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAuthorizedChatsByChatFileID indicates an expected call of GetAuthorizedChatsByChatFileID.
func (mr *MockStoreMockRecorder) GetAuthorizedChatsByChatFileID(ctx, fileID, prepared any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedChatsByChatFileID", reflect.TypeOf((*MockStore)(nil).GetAuthorizedChatsByChatFileID), ctx, fileID, prepared)
}
// GetAuthorizedConnectionLogsOffset mocks base method.
func (m *MockStore) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams, prepared rbac.PreparedAuthorized) ([]database.GetConnectionLogsOffsetRow, error) {
m.ctrl.T.Helper()
@@ -2221,6 +2236,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx,
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspacesAndAgentsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspacesAndAgentsByOwnerID), ctx, ownerID, prepared)
}
// GetChatACLByID mocks base method.
func (m *MockStore) GetChatACLByID(ctx context.Context, id uuid.UUID) (database.GetChatACLByIDRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatACLByID", ctx, id)
ret0, _ := ret[0].(database.GetChatACLByIDRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatACLByID indicates an expected call of GetChatACLByID.
func (mr *MockStoreMockRecorder) GetChatACLByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatACLByID", reflect.TypeOf((*MockStore)(nil).GetChatACLByID), ctx, id)
}
// GetChatAdvisorConfig mocks base method.
func (m *MockStore) GetChatAdvisorConfig(ctx context.Context) (string, error) {
m.ctrl.T.Helper()
@@ -3001,6 +3031,21 @@ func (mr *MockStoreMockRecorder) GetChats(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChats", reflect.TypeOf((*MockStore)(nil).GetChats), ctx, arg)
}
// GetChatsByChatFileID mocks base method.
func (m *MockStore) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID) ([]database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatsByChatFileID", ctx, fileID)
ret0, _ := ret[0].([]database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatsByChatFileID indicates an expected call of GetChatsByChatFileID.
func (mr *MockStoreMockRecorder) GetChatsByChatFileID(ctx, fileID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatsByChatFileID", reflect.TypeOf((*MockStore)(nil).GetChatsByChatFileID), ctx, fileID)
}
// GetChatsByWorkspaceIDs mocks base method.
func (m *MockStore) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.Chat, error) {
m.ctrl.T.Helper()
@@ -8774,6 +8819,20 @@ func (mr *MockStoreMockRecorder) UpdateAPIKeyByID(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAPIKeyByID", reflect.TypeOf((*MockStore)(nil).UpdateAPIKeyByID), ctx, arg)
}
// UpdateChatACLByID mocks base method.
func (m *MockStore) UpdateChatACLByID(ctx context.Context, arg database.UpdateChatACLByIDParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatACLByID", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateChatACLByID indicates an expected call of UpdateChatACLByID.
func (mr *MockStoreMockRecorder) UpdateChatACLByID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatACLByID", reflect.TypeOf((*MockStore)(nil).UpdateChatACLByID), ctx, arg)
}
// UpdateChatBuildAgentBinding mocks base method.
func (m *MockStore) UpdateChatBuildAgentBinding(ctx context.Context, arg database.UpdateChatBuildAgentBindingParams) (database.Chat, error) {
m.ctrl.T.Helper()
+21
View File
@@ -429,6 +429,25 @@ func chatFromAutoArchiveRow(logger slog.Logger, r database.AutoArchiveInactiveCh
slog.Error(err),
)
}
var userACL database.ChatACL
if err := userACL.Scan([]byte(r.UserACL)); err != nil {
logger.Warn(context.Background(), "failed to parse chat user ACL from auto-archive row",
slog.F("chat_id", r.ID),
slog.F("raw_user_acl", string(r.UserACL)),
slog.Error(err),
)
}
var groupACL database.ChatACL
if err := groupACL.Scan([]byte(r.GroupACL)); err != nil {
logger.Warn(context.Background(), "failed to parse chat group ACL from auto-archive row",
slog.F("chat_id", r.ID),
slog.F("raw_group_acl", string(r.GroupACL)),
slog.Error(err),
)
}
return database.Chat{
ID: r.ID,
OwnerID: r.OwnerID,
@@ -451,6 +470,8 @@ func chatFromAutoArchiveRow(logger slog.Logger, r database.AutoArchiveInactiveCh
Mode: r.Mode,
MCPServerIDs: r.MCPServerIDs,
Labels: labels,
UserACL: userACL,
GroupACL: groupACL,
PinOrder: r.PinOrder,
LastReadMessageID: r.LastReadMessageID,
LastInjectedContext: r.LastInjectedContext,
+11 -2
View File
@@ -242,7 +242,8 @@ CREATE TYPE api_key_scope AS ENUM (
'ai_provider:create',
'ai_provider:delete',
'ai_provider:read',
'ai_provider:update'
'ai_provider:update',
'chat:share'
);
CREATE TYPE app_sharing_level AS ENUM (
@@ -1558,6 +1559,11 @@ CREATE TABLE chats (
plan_mode chat_plan_mode,
client_type chat_client_type DEFAULT 'api'::chat_client_type NOT NULL,
last_turn_summary text,
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
group_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT chat_acl_only_on_root_chats CHECK ((((parent_chat_id IS NULL) AND (root_chat_id IS NULL)) OR ((user_acl = '{}'::jsonb) AND (group_acl = '{}'::jsonb)))),
CONSTRAINT chat_group_acl_not_null_jsonb CHECK (((group_acl IS NOT NULL) AND (jsonb_typeof(group_acl) = 'object'::text))),
CONSTRAINT chat_user_acl_not_null_jsonb CHECK (((user_acl IS NOT NULL) AND (jsonb_typeof(user_acl) = 'object'::text))),
CONSTRAINT chats_pin_order_archived_check CHECK (((pin_order = 0) OR (archived = false))),
CONSTRAINT chats_pin_order_parent_check CHECK (((pin_order = 0) OR (parent_chat_id IS NULL)))
);
@@ -1642,9 +1648,12 @@ CREATE VIEW chats_expanded AS
c.plan_mode,
c.client_type,
c.last_turn_summary,
COALESCE(root.user_acl, c.user_acl) AS user_acl,
COALESCE(root.group_acl, c.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM (chats c
FROM ((chats c
LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id))))
JOIN visible_users owner ON ((owner.id = c.owner_id)));
CREATE TABLE connection_logs (
@@ -0,0 +1,45 @@
DROP VIEW IF EXISTS chats_expanded;
ALTER TABLE chats DROP CONSTRAINT IF EXISTS chat_acl_only_on_root_chats;
ALTER TABLE chats DROP CONSTRAINT IF EXISTS chat_group_acl_not_null_jsonb;
ALTER TABLE chats DROP CONSTRAINT IF EXISTS chat_user_acl_not_null_jsonb;
ALTER TABLE chats DROP COLUMN IF EXISTS group_acl;
ALTER TABLE chats DROP COLUMN IF EXISTS user_acl;
CREATE VIEW chats_expanded AS
SELECT
c.id,
c.owner_id,
c.workspace_id,
c.title,
c.status,
c.worker_id,
c.started_at,
c.heartbeat_at,
c.created_at,
c.updated_at,
c.parent_chat_id,
c.root_chat_id,
c.last_model_config_id,
c.archived,
c.last_error,
c.mode,
c.mcp_server_ids,
c.labels,
c.build_id,
c.agent_id,
c.pin_order,
c.last_read_message_id,
c.last_injected_context,
c.dynamic_tools,
c.organization_id,
c.plan_mode,
c.client_type,
c.last_turn_summary,
owner.username AS owner_username,
owner.name AS owner_name
FROM
chats c
JOIN visible_users owner ON owner.id = c.owner_id;
-- Intentionally leave chat:share in api_key_scope because PostgreSQL cannot remove enum values.
@@ -0,0 +1,60 @@
DROP VIEW IF EXISTS chats_expanded;
ALTER TABLE chats
ADD COLUMN user_acl jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN group_acl jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE chats
ADD CONSTRAINT chat_user_acl_not_null_jsonb
CHECK (user_acl IS NOT NULL AND jsonb_typeof(user_acl) = 'object'),
ADD CONSTRAINT chat_group_acl_not_null_jsonb
CHECK (group_acl IS NOT NULL AND jsonb_typeof(group_acl) = 'object'),
ADD CONSTRAINT chat_acl_only_on_root_chats
CHECK (
(parent_chat_id IS NULL AND root_chat_id IS NULL)
OR (
user_acl = '{}'::jsonb
AND group_acl = '{}'::jsonb
)
);
CREATE VIEW chats_expanded AS
SELECT
c.id,
c.owner_id,
c.workspace_id,
c.title,
c.status,
c.worker_id,
c.started_at,
c.heartbeat_at,
c.created_at,
c.updated_at,
c.parent_chat_id,
c.root_chat_id,
c.last_model_config_id,
c.archived,
c.last_error,
c.mode,
c.mcp_server_ids,
c.labels,
c.build_id,
c.agent_id,
c.pin_order,
c.last_read_message_id,
c.last_injected_context,
c.dynamic_tools,
c.organization_id,
c.plan_mode,
c.client_type,
c.last_turn_summary,
COALESCE(root.user_acl, c.user_acl) AS user_acl,
COALESCE(root.group_acl, c.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
chats c
LEFT JOIN chats root ON root.id = COALESCE(c.root_chat_id, c.parent_chat_id)
JOIN visible_users owner ON owner.id = c.owner_id;
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat:share';
+16 -1
View File
@@ -194,7 +194,22 @@ func (t Task) RBACObject() rbac.Object {
}
func (c Chat) RBACObject() rbac.Object {
return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID)
obj := rbac.ResourceChat.
WithID(c.ID).
WithOwner(c.OwnerID.String()).
InOrg(c.OrganizationID)
if rbac.ChatACLDisabled() {
return obj
}
return obj.
WithACLUserList(c.UserACL.RBACACL()).
WithGroupACL(c.GroupACL.RBACACL())
}
func (c Chat) IsSubChat() bool {
return c.RootChatID.Valid || c.ParentChatID.Valid
}
func (r GetChatsRow) RBACObject() rbac.Object {
@@ -143,6 +143,45 @@ func TestAPIKeyScopesExpand(t *testing.T) {
})
}
//nolint:tparallel,paralleltest
func TestChatACLDisabled(t *testing.T) {
uid := uuid.NewString()
gid := uuid.NewString()
chat := Chat{
ID: uuid.New(),
OrganizationID: uuid.New(),
OwnerID: uuid.New(),
UserACL: ChatACL{
uid: ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}},
},
GroupACL: ChatACL{
gid: ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}},
},
}
t.Run("ACLsOmittedWhenDisabled", func(t *testing.T) {
rbac.SetChatACLDisabled(true)
t.Cleanup(func() { rbac.SetChatACLDisabled(false) })
obj := chat.RBACObject()
require.Empty(t, obj.ACLUserList, "user ACLs should be empty when disabled")
require.Empty(t, obj.ACLGroupList, "group ACLs should be empty when disabled")
})
t.Run("ACLsIncludedWhenEnabled", func(t *testing.T) {
rbac.SetChatACLDisabled(false)
obj := chat.RBACObject()
require.NotEmpty(t, obj.ACLUserList, "user ACLs should be present when enabled")
require.NotEmpty(t, obj.ACLGroupList, "group ACLs should be present when enabled")
require.Contains(t, obj.ACLUserList, uid)
require.Contains(t, obj.ACLGroupList, gid)
})
}
//nolint:tparallel,paralleltest
func TestWorkspaceACLDisabled(t *testing.T) {
uid := uuid.NewString()
+79 -1
View File
@@ -744,9 +744,17 @@ func (q *sqlQuerier) CountAuthorizedConnectionLogs(ctx context.Context, arg Coun
type chatQuerier interface {
GetAuthorizedChats(ctx context.Context, arg GetChatsParams, prepared rbac.PreparedAuthorized) ([]GetChatsRow, error)
GetAuthorizedChatsByChatFileID(ctx context.Context, fileID uuid.UUID, prepared rbac.PreparedAuthorized) ([]Chat, error)
}
func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, prepared rbac.PreparedAuthorized) ([]GetChatsRow, error) {
if arg.OwnedOnly && arg.SharedOnly {
return nil, xerrors.New("owned_only and shared_only cannot both be true")
}
if (arg.OwnedOnly || arg.SharedOnly) && arg.ViewerID == uuid.Nil {
return nil, xerrors.New("viewer_id required when owned_only or shared_only is true")
}
authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigChats())
if err != nil {
return nil, xerrors.Errorf("compile authorized filter: %w", err)
@@ -760,7 +768,9 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
// The name comment is for metric tracking
query := fmt.Sprintf("-- name: GetAuthorizedChats :many\n%s", filtered)
rows, err := q.db.QueryContext(ctx, query,
arg.OwnerID,
arg.OwnedOnly,
arg.ViewerID,
arg.SharedOnly,
arg.Archived,
arg.AfterID,
arg.LabelFilter,
@@ -804,6 +814,8 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
&i.Chat.PlanMode,
&i.Chat.ClientType,
&i.Chat.LastTurnSummary,
&i.Chat.UserACL,
&i.Chat.GroupACL,
&i.Chat.OwnerUsername,
&i.Chat.OwnerName,
&i.HasUnread); err != nil {
@@ -820,6 +832,72 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
return items, nil
}
func (q *sqlQuerier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID uuid.UUID, prepared rbac.PreparedAuthorized) ([]Chat, error) {
authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigChats())
if err != nil {
return nil, xerrors.Errorf("compile authorized filter: %w", err)
}
filtered, err := insertAuthorizedFilter(getChatsByChatFileID, fmt.Sprintf(" AND %s\nLIMIT 1", authorizedFilter))
if err != nil {
return nil, xerrors.Errorf("insert authorized filter: %w", err)
}
query := fmt.Sprintf("-- name: GetAuthorizedChatsByChatFileID :many\n%s", filtered)
rows, err := q.db.QueryContext(ctx, query, fileID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Chat
for rows.Next() {
var i Chat
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.WorkspaceID,
&i.Title,
&i.Status,
&i.WorkerID,
&i.StartedAt,
&i.HeartbeatAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentChatID,
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
&i.Mode,
pq.Array(&i.MCPServerIDs),
&i.Labels,
&i.BuildID,
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
type aibridgeQuerier interface {
ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeInterceptionsRow, error)
CountAuthorizedAIBridgeInterceptions(ctx context.Context, arg CountAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) (int64, error)
+8 -1
View File
@@ -311,6 +311,7 @@ const (
ApiKeyScopeAiProviderDelete APIKeyScope = "ai_provider:delete"
ApiKeyScopeAiProviderRead APIKeyScope = "ai_provider:read"
ApiKeyScopeAiProviderUpdate APIKeyScope = "ai_provider:update"
ApiKeyScopeChatShare APIKeyScope = "chat:share"
)
func (e *APIKeyScope) Scan(src interface{}) error {
@@ -565,7 +566,8 @@ func (e APIKeyScope) Valid() bool {
ApiKeyScopeAiProviderCreate,
ApiKeyScopeAiProviderDelete,
ApiKeyScopeAiProviderRead,
ApiKeyScopeAiProviderUpdate:
ApiKeyScopeAiProviderUpdate,
ApiKeyScopeChatShare:
return true
}
return false
@@ -789,6 +791,7 @@ func AllAPIKeyScopeValues() []APIKeyScope {
ApiKeyScopeAiProviderDelete,
ApiKeyScopeAiProviderRead,
ApiKeyScopeAiProviderUpdate,
ApiKeyScopeChatShare,
}
}
@@ -4533,6 +4536,8 @@ type Chat struct {
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
ClientType ChatClientType `db:"client_type" json:"client_type"`
LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"`
UserACL ChatACL `db:"user_acl" json:"user_acl"`
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
OwnerUsername string `db:"owner_username" json:"owner_username"`
OwnerName string `db:"owner_name" json:"owner_name"`
}
@@ -4712,6 +4717,8 @@ type ChatTable struct {
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
ClientType ChatClientType `db:"client_type" json:"client_type"`
LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"`
UserACL ChatACL `db:"user_acl" json:"user_acl"`
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
}
type ChatUsageLimitConfig struct {
+3
View File
@@ -298,6 +298,7 @@ type sqlcQuerier interface {
// This function returns roles for authorization purposes. Implied member roles
// are included.
GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error)
GetChatACLByID(ctx context.Context, id uuid.UUID) (GetChatACLByIDRow, error)
// GetChatAdvisorConfig returns the deployment-wide runtime configuration
// for the experimental chat advisor as a JSON blob. Callers unmarshal the
// result into codersdk.AdvisorConfig. Returns '{}' when unset so zero
@@ -411,6 +412,7 @@ type sqlcQuerier interface {
// Returns "0s" (disabled) when no value has been configured.
GetChatWorkspaceTTL(ctx context.Context) (string, error)
GetChats(ctx context.Context, arg GetChatsParams) ([]GetChatsRow, error)
GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID) ([]Chat, error)
GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]Chat, error)
// Retrieves chats updated after the given timestamp for telemetry
// snapshot collection. Uses updated_at so that long-running chats
@@ -1132,6 +1134,7 @@ type sqlcQuerier interface {
UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error)
UpdateAIProvider(ctx context.Context, arg UpdateAIProviderParams) (AIProvider, error)
UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error
UpdateChatACLByID(ctx context.Context, arg UpdateChatACLByIDParams) error
UpdateChatBuildAgentBinding(ctx context.Context, arg UpdateChatBuildAgentBindingParams) (Chat, error)
UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) (Chat, error)
// Uses COALESCE so that passing NULL from Go means "keep the
+316 -10
View File
@@ -1362,19 +1362,31 @@ func TestGetAuthorizedChats(t *testing.T) {
require.NoError(t, err)
require.GreaterOrEqual(t, len(sameOrgAdminRows), 5, "same-org admin should see all chats in their org")
// OwnerID filter: member queries their own chats.
// OwnedOnly filter: member queries their own chats.
memberFilterSelf, err := db.GetAuthorizedChats(ctx, database.GetChatsParams{
OwnerID: member.ID,
OwnedOnly: true,
ViewerID: member.ID,
}, preparedMember)
require.NoError(t, err)
require.Len(t, memberFilterSelf, 2)
// OwnerID filter: member queries owner's chats sees 0.
// OwnedOnly filter: member queries owner's chats and sees 0.
memberFilterOwner, err := db.GetAuthorizedChats(ctx, database.GetChatsParams{
OwnerID: owner.ID,
OwnedOnly: true,
ViewerID: owner.ID,
}, preparedMember)
require.NoError(t, err)
require.Len(t, memberFilterOwner, 0)
_, err = db.GetAuthorizedChats(ctx, database.GetChatsParams{
OwnedOnly: true,
}, preparedMember)
require.ErrorContains(t, err, "viewer_id required")
_, err = db.GetAuthorizedChats(ctx, database.GetChatsParams{
SharedOnly: true,
}, preparedMember)
require.ErrorContains(t, err, "viewer_id required")
})
t.Run("dbauthz", func(t *testing.T) {
@@ -1470,6 +1482,293 @@ func TestGetAuthorizedChats(t *testing.T) {
})
}
//nolint:tparallel,paralleltest // It toggles the global chat ACL flag.
func TestGetAuthorizedChatsACLSharing(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
rbac.SetChatACLDisabled(false)
t.Cleanup(func() { rbac.SetChatACLDisabled(false) })
ctx := testutil.Context(t, testutil.WaitMedium)
sqlDB := testSQLDB(t)
err := migrations.Up(sqlDB)
require.NoError(t, err)
db := database.New(sqlDB)
authorizer := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
owner := dbgen.User(t, db, database.User{})
recipient := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: owner.ID,
OrganizationID: org.ID,
Roles: []string{rbac.RoleAgentsAccess()},
})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: recipient.ID,
OrganizationID: org.ID,
Roles: []string{rbac.RoleAgentsAccess()},
})
dbgen.ChatProvider(t, db, database.ChatProvider{Provider: "openai", DisplayName: "OpenAI"})
modelCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Provider: "openai",
Model: "test-model",
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
IsDefault: true,
CompressionThreshold: 80,
})
ownerChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: "shared owner chat",
})
recipientChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: recipient.ID,
LastModelConfigID: modelCfg.ID,
Title: "recipient chat",
})
sharedACL := database.ChatACL{
recipient.ID.String(): database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}},
}
err = db.UpdateChatACLByID(ctx, database.UpdateChatACLByIDParams{
ID: ownerChat.ID,
UserACL: sharedACL,
GroupACL: database.ChatACL{},
})
require.NoError(t, err)
recipientSubject, _, err := httpmw.UserRBACSubject(ctx, db, recipient.ID, rbac.ExpandableScope(rbac.ScopeAll))
require.NoError(t, err)
preparedRecipient, err := authorizer.Prepare(ctx, recipientSubject, policy.ActionRead, rbac.ResourceChat.Type)
require.NoError(t, err)
chatIDs := func(rows []database.GetChatsRow) []uuid.UUID {
ids := make([]uuid.UUID, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.Chat.ID)
}
return ids
}
rows, err := db.GetAuthorizedChats(ctx, database.GetChatsParams{}, preparedRecipient)
require.NoError(t, err)
require.ElementsMatch(t, []uuid.UUID{ownerChat.ID, recipientChat.ID}, chatIDs(rows))
sharedOnly, err := db.GetAuthorizedChats(ctx, database.GetChatsParams{
SharedOnly: true,
ViewerID: recipient.ID,
}, preparedRecipient)
require.NoError(t, err)
require.ElementsMatch(t, []uuid.UUID{ownerChat.ID}, chatIDs(sharedOnly))
require.Equal(t, sharedACL, sharedOnly[0].Chat.UserACL)
require.Empty(t, sharedOnly[0].Chat.GroupACL)
_, err = db.GetAuthorizedChats(ctx, database.GetChatsParams{
OwnedOnly: true,
SharedOnly: true,
ViewerID: recipient.ID,
}, preparedRecipient)
require.ErrorContains(t, err, "owned_only and shared_only")
authzdb := dbauthz.New(db, authorizer, slogtest.Make(t, &slogtest.Options{}), coderdtest.AccessControlStorePointer())
recipientCtx := dbauthz.As(ctx, recipientSubject)
authzRows, err := authzdb.GetChats(recipientCtx, database.GetChatsParams{})
require.NoError(t, err)
require.ElementsMatch(t, []uuid.UUID{ownerChat.ID, recipientChat.ID}, chatIDs(authzRows))
rbac.SetChatACLDisabled(true)
disabledRows, err := db.GetAuthorizedChats(ctx, database.GetChatsParams{}, preparedRecipient)
require.NoError(t, err)
require.ElementsMatch(t, []uuid.UUID{recipientChat.ID}, chatIDs(disabledRows))
}
//nolint:tparallel,paralleltest // It toggles the global chat ACL flag.
func TestGetAuthorizedChatsACLSharingGroupACL(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
rbac.SetChatACLDisabled(false)
t.Cleanup(func() { rbac.SetChatACLDisabled(false) })
ctx := testutil.Context(t, testutil.WaitMedium)
sqlDB := testSQLDB(t)
err := migrations.Up(sqlDB)
require.NoError(t, err)
db := database.New(sqlDB)
authorizer := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
owner := dbgen.User(t, db, database.User{})
recipient := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: owner.ID,
OrganizationID: org.ID,
Roles: []string{rbac.RoleAgentsAccess()},
})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: recipient.ID,
OrganizationID: org.ID,
Roles: []string{rbac.RoleAgentsAccess()},
})
group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
dbgen.GroupMember(t, db, database.GroupMemberTable{UserID: recipient.ID, GroupID: group.ID})
dbgen.ChatProvider(t, db, database.ChatProvider{Provider: "openai", DisplayName: "OpenAI"})
modelCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Provider: "openai",
Model: "test-model",
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
IsDefault: true,
CompressionThreshold: 80,
})
ownerChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: "shared owner chat",
})
recipientChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: recipient.ID,
LastModelConfigID: modelCfg.ID,
Title: "recipient chat",
})
sharedGroupACL := database.ChatACL{
group.ID.String(): database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}},
}
err = db.UpdateChatACLByID(ctx, database.UpdateChatACLByIDParams{
ID: ownerChat.ID,
UserACL: database.ChatACL{},
GroupACL: sharedGroupACL,
})
require.NoError(t, err)
recipientSubject, _, err := httpmw.UserRBACSubject(ctx, db, recipient.ID, rbac.ExpandableScope(rbac.ScopeAll))
require.NoError(t, err)
preparedRecipient, err := authorizer.Prepare(ctx, recipientSubject, policy.ActionRead, rbac.ResourceChat.Type)
require.NoError(t, err)
chatIDs := func(rows []database.GetChatsRow) []uuid.UUID {
ids := make([]uuid.UUID, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.Chat.ID)
}
return ids
}
rows, err := db.GetAuthorizedChats(ctx, database.GetChatsParams{}, preparedRecipient)
require.NoError(t, err)
require.ElementsMatch(t, []uuid.UUID{ownerChat.ID, recipientChat.ID}, chatIDs(rows))
sharedOnly, err := db.GetAuthorizedChats(ctx, database.GetChatsParams{
SharedOnly: true,
ViewerID: recipient.ID,
}, preparedRecipient)
require.NoError(t, err)
require.Len(t, sharedOnly, 1)
require.Equal(t, ownerChat.ID, sharedOnly[0].Chat.ID)
require.Empty(t, sharedOnly[0].Chat.UserACL)
require.Equal(t, sharedGroupACL, sharedOnly[0].Chat.GroupACL)
}
//nolint:tparallel,paralleltest // It toggles the global chat ACL flag.
func TestGetAuthorizedChatsByChatFileIDACLSharing(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
rbac.SetChatACLDisabled(false)
t.Cleanup(func() { rbac.SetChatACLDisabled(false) })
ctx := testutil.Context(t, testutil.WaitMedium)
sqlDB := testSQLDB(t)
err := migrations.Up(sqlDB)
require.NoError(t, err)
db := database.New(sqlDB)
authorizer := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
owner := dbgen.User(t, db, database.User{})
recipient := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: owner.ID,
OrganizationID: org.ID,
Roles: []string{rbac.RoleAgentsAccess()},
})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: recipient.ID,
OrganizationID: org.ID,
Roles: []string{rbac.RoleAgentsAccess()},
})
dbgen.ChatProvider(t, db, database.ChatProvider{Provider: "openai", DisplayName: "OpenAI"})
modelCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Provider: "openai",
Model: "test-model",
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
IsDefault: true,
CompressionThreshold: 80,
})
ownerChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: "shared owner chat",
})
sharedACL := database.ChatACL{
recipient.ID.String(): database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}},
}
err = db.UpdateChatACLByID(ctx, database.UpdateChatACLByIDParams{
ID: ownerChat.ID,
UserACL: sharedACL,
GroupACL: database.ChatACL{},
})
require.NoError(t, err)
fileRow, err := db.InsertChatFile(ctx, database.InsertChatFileParams{
OwnerID: owner.ID,
OrganizationID: org.ID,
Name: "shared.txt",
Mimetype: "text/plain",
Data: []byte("shared file"),
})
require.NoError(t, err)
rejected, err := db.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: ownerChat.ID,
FileIds: []uuid.UUID{fileRow.ID},
MaxFileLinks: 10,
})
require.NoError(t, err)
require.Zero(t, rejected)
recipientSubject, _, err := httpmw.UserRBACSubject(ctx, db, recipient.ID, rbac.ExpandableScope(rbac.ScopeAll))
require.NoError(t, err)
preparedRecipient, err := authorizer.Prepare(ctx, recipientSubject, policy.ActionRead, rbac.ResourceChat.Type)
require.NoError(t, err)
rows, err := db.GetAuthorizedChatsByChatFileID(ctx, fileRow.ID, preparedRecipient)
require.NoError(t, err)
require.Len(t, rows, 1)
require.Equal(t, ownerChat.ID, rows[0].ID)
require.Equal(t, sharedACL, rows[0].UserACL)
require.Empty(t, rows[0].GroupACL)
}
func TestInsertWorkspaceAgentLogs(t *testing.T) {
t.Parallel()
if testing.Short() {
@@ -11783,7 +12082,10 @@ func TestChatLabels(t *testing.T) {
})
require.NoError(t, err)
rows, err := db.GetChats(ctx, database.GetChatsParams{OwnerID: owner.ID})
rows, err := db.GetChats(ctx, database.GetChatsParams{
OwnedOnly: true,
ViewerID: owner.ID,
})
require.NoError(t, err)
chatIndex := slices.IndexFunc(rows, func(row database.GetChatsRow) bool {
@@ -11935,7 +12237,8 @@ func TestChatLabels(t *testing.T) {
filterJSON, err := json.Marshal(database.StringMap{"env": "prod"})
require.NoError(t, err)
results, err := db.GetChats(ctx, database.GetChatsParams{
OwnerID: owner.ID,
OwnedOnly: true,
ViewerID: owner.ID,
LabelFilter: pqtype.NullRawMessage{
RawMessage: filterJSON,
Valid: true,
@@ -11955,7 +12258,8 @@ func TestChatLabels(t *testing.T) {
filterJSON, err = json.Marshal(database.StringMap{"env": "prod", "team": "backend"})
require.NoError(t, err)
results, err = db.GetChats(ctx, database.GetChatsParams{
OwnerID: owner.ID,
OwnedOnly: true,
ViewerID: owner.ID,
LabelFilter: pqtype.NullRawMessage{
RawMessage: filterJSON,
Valid: true,
@@ -11964,9 +12268,10 @@ func TestChatLabels(t *testing.T) {
require.NoError(t, err)
require.Len(t, results, 1)
require.Equal(t, "filter-a", results[0].Chat.Title)
// No filter should return all chats for this owner.
// No filter should return all chats for this owner.
allChats, err := db.GetChats(ctx, database.GetChatsParams{
OwnerID: owner.ID,
OwnedOnly: true,
ViewerID: owner.ID,
})
require.NoError(t, err)
require.GreaterOrEqual(t, len(allChats), 3)
@@ -13688,7 +13993,8 @@ func TestChatHasUnread(t *testing.T) {
getHasUnread := func() bool {
rows, err := store.GetChats(ctx, database.GetChatsParams{
OwnerID: user.ID,
OwnedOnly: true,
ViewerID: user.ID,
})
require.NoError(t, err)
for _, row := range rows {
+272 -53
View File
@@ -5728,7 +5728,7 @@ WHERE
LIMIT
$3::int
)
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -5760,13 +5760,16 @@ chats_expanded AS (
acquired_chats.plan_mode,
acquired_chats.client_type,
acquired_chats.last_turn_summary,
COALESCE(root.user_acl, acquired_chats.user_acl) AS user_acl,
COALESCE(root.group_acl, acquired_chats.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
acquired_chats
LEFT JOIN chats root ON root.id = COALESCE(acquired_chats.root_chat_id, acquired_chats.parent_chat_id)
JOIN visible_users owner ON owner.id = acquired_chats.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -5816,6 +5819,8 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) (
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
); err != nil {
@@ -5956,7 +5961,7 @@ WITH updated_chats AS (
UPDATE chats
SET archived = true, pin_order = 0, updated_at = NOW()
WHERE id = $1::uuid OR root_chat_id = $1::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -5988,13 +5993,16 @@ chats_expanded AS (
updated_chats.plan_mode,
updated_chats.client_type,
updated_chats.last_turn_summary,
COALESCE(root.user_acl, updated_chats.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chats.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chats
LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chats.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC
`
@@ -6037,6 +6045,8 @@ func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
); err != nil {
@@ -6088,10 +6098,10 @@ archived AS (
FROM to_archive t
WHERE (c.id = t.id OR c.root_chat_id = t.id) -- cascade to children
AND c.archived = false
RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.last_injected_context, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary
RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.last_injected_context, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.user_acl, c.group_acl
)
SELECT
a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.last_injected_context, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary,
a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.last_injected_context, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary, a.user_acl, a.group_acl,
-- Children inherit their root's activity so last_activity_at is never null.
COALESCE(
t.last_activity_at,
@@ -6137,6 +6147,8 @@ type AutoArchiveInactiveChatsRow struct {
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
ClientType ChatClientType `db:"client_type" json:"client_type"`
LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"`
UserACL json.RawMessage `db:"user_acl" json:"user_acl"`
GroupACL json.RawMessage `db:"group_acl" json:"group_acl"`
LastActivityAt time.Time `db:"last_activity_at" json:"last_activity_at"`
}
@@ -6183,6 +6195,8 @@ func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchi
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.LastActivityAt,
); err != nil {
return nil, err
@@ -6333,7 +6347,7 @@ func (q *sqlQuerier) DeleteOldChats(ctx context.Context, arg DeleteOldChatsParam
}
const getActiveChatsByAgentID = `-- name: GetActiveChatsByAgentID :many
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
WHERE agent_id = $1::uuid
AND archived = false
@@ -6382,6 +6396,8 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
); err != nil {
@@ -6398,8 +6414,30 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U
return items, nil
}
const getChatACLByID = `-- name: GetChatACLByID :one
SELECT
user_acl AS users,
group_acl AS groups
FROM
chats
WHERE
id = $1::uuid
`
type GetChatACLByIDRow struct {
Users ChatACL `db:"users" json:"users"`
Groups ChatACL `db:"groups" json:"groups"`
}
func (q *sqlQuerier) GetChatACLByID(ctx context.Context, id uuid.UUID) (GetChatACLByIDRow, error) {
row := q.db.QueryRowContext(ctx, getChatACLByID, id)
var i GetChatACLByIDRow
err := row.Scan(&i.Users, &i.Groups)
return i, err
}
const getChatByID = `-- name: GetChatByID :one
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
WHERE id = $1::uuid
`
@@ -6436,6 +6474,8 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
@@ -6444,7 +6484,7 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error
const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one
WITH locked_chat AS (
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
FROM chats
WHERE id = $1::uuid
FOR UPDATE
@@ -6479,12 +6519,16 @@ chats_expanded AS (
locked_chat.plan_mode,
locked_chat.client_type,
locked_chat.last_turn_summary,
COALESCE(root.user_acl, locked_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, locked_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM locked_chat
FROM
locked_chat
LEFT JOIN chats root ON root.id = COALESCE(locked_chat.root_chat_id, locked_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = locked_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -6520,6 +6564,8 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
@@ -7700,10 +7746,10 @@ WITH cursor_chat AS (
updated_at,
id
FROM chats
WHERE id = $3
WHERE id = $5
)
SELECT
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.owner_username, chats_expanded.owner_name,
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name,
EXISTS (
SELECT 1 FROM chat_messages cm
WHERE cm.chat_id = chats_expanded.id
@@ -7715,19 +7761,23 @@ FROM
chats_expanded
WHERE
CASE
WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN chats_expanded.owner_id = $1
WHEN $1::boolean THEN chats_expanded.owner_id = $2::uuid
ELSE true
END
AND CASE
WHEN $2 :: boolean IS NULL THEN true
ELSE chats_expanded.archived = $2 :: boolean
WHEN $3::boolean THEN chats_expanded.owner_id != $2::uuid
ELSE true
END
AND CASE
WHEN $4 :: boolean IS NULL THEN true
ELSE chats_expanded.archived = $4 :: boolean
END
AND CASE
-- Cursor pagination: the last element on a page acts as the cursor.
-- The 4-tuple matches the ORDER BY below. All columns sort DESC
-- (pin_order is negated so lower values sort first in DESC order),
-- which lets us use a single tuple < comparison.
WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
WHEN $5 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
(CASE WHEN chats_expanded.pin_order > 0 THEN 1 ELSE 0 END, -chats_expanded.pin_order, chats_expanded.updated_at, chats_expanded.id) < (
SELECT
CASE WHEN cursor_chat.pin_order > 0 THEN 1 ELSE 0 END,
@@ -7741,7 +7791,7 @@ WHERE
ELSE true
END
AND CASE
WHEN $4::jsonb IS NOT NULL THEN chats_expanded.labels @> $4::jsonb
WHEN $6::jsonb IS NOT NULL THEN chats_expanded.labels @> $6::jsonb
ELSE true
END
-- Match chats whose linked diff URL (e.g. a pull request URL)
@@ -7749,13 +7799,13 @@ WHERE
-- a delegated sub-agent's diff status, so we surface the root chat
-- when any descendant matches.
AND CASE
WHEN $5::text IS NOT NULL THEN EXISTS (
WHEN $7::text IS NOT NULL THEN EXISTS (
SELECT 1
FROM chat_diff_statuses cds
JOIN chats c2 ON c2.id = cds.chat_id
WHERE cds.url IS NOT NULL
AND cds.url <> ''
AND LOWER(cds.url) = LOWER($5::text)
AND LOWER(cds.url) = LOWER($7::text)
AND (c2.id = chats_expanded.id OR c2.root_chat_id = chats_expanded.id)
)
ELSE true
@@ -7776,15 +7826,17 @@ ORDER BY
-chats_expanded.pin_order DESC,
chats_expanded.updated_at DESC,
chats_expanded.id DESC
OFFSET $6
OFFSET $8
LIMIT
-- The chat list is unbounded and expected to grow large.
-- Default to 50 to prevent accidental excessively large queries.
COALESCE(NULLIF($7 :: int, 0), 50)
COALESCE(NULLIF($9 :: int, 0), 50)
`
type GetChatsParams struct {
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
OwnedOnly bool `db:"owned_only" json:"owned_only"`
ViewerID uuid.UUID `db:"viewer_id" json:"viewer_id"`
SharedOnly bool `db:"shared_only" json:"shared_only"`
Archived sql.NullBool `db:"archived" json:"archived"`
AfterID uuid.UUID `db:"after_id" json:"after_id"`
LabelFilter pqtype.NullRawMessage `db:"label_filter" json:"label_filter"`
@@ -7800,7 +7852,9 @@ type GetChatsRow struct {
func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetChatsRow, error) {
rows, err := q.db.QueryContext(ctx, getChats,
arg.OwnerID,
arg.OwnedOnly,
arg.ViewerID,
arg.SharedOnly,
arg.Archived,
arg.AfterID,
arg.LabelFilter,
@@ -7844,6 +7898,8 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
&i.Chat.PlanMode,
&i.Chat.ClientType,
&i.Chat.LastTurnSummary,
&i.Chat.UserACL,
&i.Chat.GroupACL,
&i.Chat.OwnerUsername,
&i.Chat.OwnerName,
&i.HasUnread,
@@ -7861,8 +7917,79 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
return items, nil
}
const getChatsByChatFileID = `-- name: GetChatsByChatFileID :many
SELECT
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM
chats_expanded
WHERE
id IN (
SELECT chat_id
FROM chat_file_links
WHERE file_id = $1::uuid
)
-- Authorize Filter clause will be injected below in GetAuthorizedChatsByChatFileID.
-- @authorize_filter
`
func (q *sqlQuerier) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID) ([]Chat, error) {
rows, err := q.db.QueryContext(ctx, getChatsByChatFileID, fileID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Chat
for rows.Next() {
var i Chat
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.WorkspaceID,
&i.Title,
&i.Status,
&i.WorkerID,
&i.StartedAt,
&i.HeartbeatAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentChatID,
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
&i.Mode,
pq.Array(&i.MCPServerIDs),
&i.Labels,
&i.BuildID,
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getChatsByWorkspaceIDs = `-- name: GetChatsByWorkspaceIDs :many
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
WHERE archived = false
AND workspace_id = ANY($1::uuid[])
@@ -7907,6 +8034,8 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
); err != nil {
@@ -7993,7 +8122,7 @@ func (q *sqlQuerier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time
const getChildChatsByParentIDs = `-- name: GetChildChatsByParentIDs :many
SELECT
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.owner_username, chats_expanded.owner_name,
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name,
EXISTS (
SELECT 1 FROM chat_messages cm
WHERE cm.chat_id = chats_expanded.id
@@ -8066,6 +8195,8 @@ func (q *sqlQuerier) GetChildChatsByParentIDs(ctx context.Context, arg GetChildC
&i.Chat.PlanMode,
&i.Chat.ClientType,
&i.Chat.LastTurnSummary,
&i.Chat.UserACL,
&i.Chat.GroupACL,
&i.Chat.OwnerUsername,
&i.Chat.OwnerName,
&i.HasUnread,
@@ -8134,7 +8265,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh
const getStaleChats = `-- name: GetStaleChats :many
SELECT
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM
chats_expanded
WHERE
@@ -8195,6 +8326,8 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
); err != nil {
@@ -8310,7 +8443,7 @@ INSERT INTO chats (
$15::jsonb,
$16::chat_client_type
)
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -8342,13 +8475,16 @@ chats_expanded AS (
inserted_chat.plan_mode,
inserted_chat.client_type,
inserted_chat.last_turn_summary,
COALESCE(root.user_acl, inserted_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, inserted_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
inserted_chat
LEFT JOIN chats root ON root.id = COALESCE(inserted_chat.root_chat_id, inserted_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = inserted_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -8420,6 +8556,8 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
@@ -8982,7 +9120,7 @@ WITH updated_chats AS (
archived = false,
updated_at = NOW()
WHERE id = $1::uuid OR root_chat_id = $1::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -9014,13 +9152,16 @@ chats_expanded AS (
updated_chats.plan_mode,
updated_chats.client_type,
updated_chats.last_turn_summary,
COALESCE(root.user_acl, updated_chats.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chats.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chats
LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chats.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC
`
@@ -9067,6 +9208,8 @@ func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Cha
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
); err != nil {
@@ -9142,6 +9285,27 @@ func (q *sqlQuerier) UnpinChatByID(ctx context.Context, id uuid.UUID) error {
return err
}
const updateChatACLByID = `-- name: UpdateChatACLByID :exec
UPDATE
chats
SET
user_acl = $1,
group_acl = $2
WHERE
id = $3::uuid
`
type UpdateChatACLByIDParams struct {
UserACL ChatACL `db:"user_acl" json:"user_acl"`
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateChatACLByID(ctx context.Context, arg UpdateChatACLByIDParams) error {
_, err := q.db.ExecContext(ctx, updateChatACLByID, arg.UserACL, arg.GroupACL, arg.ID)
return err
}
const updateChatBuildAgentBinding = `-- name: UpdateChatBuildAgentBinding :one
WITH updated_chat AS (
UPDATE chats SET
@@ -9150,7 +9314,7 @@ UPDATE chats SET
updated_at = NOW()
WHERE
id = $3::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -9182,13 +9346,16 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -9230,6 +9397,8 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
@@ -9245,7 +9414,7 @@ SET
updated_at = NOW()
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -9277,13 +9446,16 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -9324,6 +9496,8 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
@@ -9384,7 +9558,7 @@ SET
updated_at = NOW()
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -9416,13 +9590,16 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -9463,6 +9640,8 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
@@ -9475,7 +9654,7 @@ UPDATE chats SET
last_injected_context = $1::jsonb
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -9507,13 +9686,16 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -9558,6 +9740,8 @@ func (q *sqlQuerier) UpdateChatLastInjectedContext(ctx context.Context, arg Upda
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
@@ -9573,7 +9757,7 @@ SET
last_model_config_id = $1::uuid
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -9605,13 +9789,16 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -9652,6 +9839,8 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
@@ -9719,7 +9908,7 @@ SET
updated_at = NOW()
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -9751,13 +9940,16 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -9798,6 +9990,8 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
@@ -9931,7 +10125,7 @@ SET
plan_mode = $1::chat_plan_mode
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -9963,13 +10157,16 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -10010,6 +10207,8 @@ func (q *sqlQuerier) UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatP
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
@@ -10029,7 +10228,7 @@ SET
updated_at = NOW()
WHERE
id = $6::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -10061,13 +10260,16 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -10119,6 +10321,8 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
@@ -10138,7 +10342,7 @@ SET
updated_at = $6::timestamptz
WHERE
id = $7::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -10170,13 +10374,16 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -10230,6 +10437,8 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
@@ -10247,7 +10456,7 @@ SET
title = $1::text
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -10279,13 +10488,16 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -10326,6 +10538,8 @@ func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitl
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
@@ -10340,7 +10554,7 @@ UPDATE chats SET
agent_id = $3::uuid,
updated_at = NOW()
WHERE id = $4::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl
),
chats_expanded AS (
SELECT
@@ -10372,13 +10586,16 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, owner_username, owner_name
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, owner_username, owner_name
FROM chats_expanded
`
@@ -10426,6 +10643,8 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
)
+88 -2
View File
@@ -35,10 +35,13 @@ chats_expanded AS (
updated_chats.plan_mode,
updated_chats.client_type,
updated_chats.last_turn_summary,
COALESCE(root.user_acl, updated_chats.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chats.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chats
LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chats.owner_id
)
SELECT *
@@ -87,10 +90,13 @@ chats_expanded AS (
updated_chats.plan_mode,
updated_chats.client_type,
updated_chats.last_turn_summary,
COALESCE(root.user_acl, updated_chats.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chats.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chats
LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chats.owner_id
)
SELECT *
@@ -287,6 +293,24 @@ SELECT *
FROM chats_expanded
WHERE id = @id::uuid;
-- name: GetChatACLByID :one
SELECT
user_acl AS users,
group_acl AS groups
FROM
chats
WHERE
id = @id::uuid;
-- name: UpdateChatACLByID :exec
UPDATE
chats
SET
user_acl = @user_acl,
group_acl = @group_acl
WHERE
id = @id::uuid;
-- name: GetChatMessageByID :one
SELECT
*
@@ -458,7 +482,11 @@ FROM
chats_expanded
WHERE
CASE
WHEN @owner_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN chats_expanded.owner_id = @owner_id
WHEN @owned_only::boolean THEN chats_expanded.owner_id = @viewer_id::uuid
ELSE true
END
AND CASE
WHEN @shared_only::boolean THEN chats_expanded.owner_id != @viewer_id::uuid
ELSE true
END
AND CASE
@@ -620,10 +648,13 @@ chats_expanded AS (
inserted_chat.plan_mode,
inserted_chat.client_type,
inserted_chat.last_turn_summary,
COALESCE(root.user_acl, inserted_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, inserted_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
inserted_chat
LEFT JOIN chats root ON root.id = COALESCE(inserted_chat.root_chat_id, inserted_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = inserted_chat.owner_id
)
SELECT *
@@ -752,10 +783,13 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
@@ -804,10 +838,13 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
@@ -854,10 +891,13 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
@@ -904,10 +944,13 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
@@ -954,10 +997,13 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
@@ -1003,10 +1049,13 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
@@ -1052,10 +1101,13 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
@@ -1103,10 +1155,13 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
@@ -1172,10 +1227,13 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
@@ -1278,10 +1336,13 @@ chats_expanded AS (
acquired_chats.plan_mode,
acquired_chats.client_type,
acquired_chats.last_turn_summary,
COALESCE(root.user_acl, acquired_chats.user_acl) AS user_acl,
COALESCE(root.group_acl, acquired_chats.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
acquired_chats
LEFT JOIN chats root ON root.id = COALESCE(acquired_chats.root_chat_id, acquired_chats.parent_chat_id)
JOIN visible_users owner ON owner.id = acquired_chats.owner_id
)
SELECT *
@@ -1332,10 +1393,13 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
@@ -1386,10 +1450,13 @@ chats_expanded AS (
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
@@ -1641,14 +1708,33 @@ chats_expanded AS (
locked_chat.plan_mode,
locked_chat.client_type,
locked_chat.last_turn_summary,
COALESCE(root.user_acl, locked_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, locked_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name
FROM locked_chat
FROM
locked_chat
LEFT JOIN chats root ON root.id = COALESCE(locked_chat.root_chat_id, locked_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = locked_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: GetChatsByChatFileID :many
SELECT
*
FROM
chats_expanded
WHERE
id IN (
SELECT chat_id
FROM chat_file_links
WHERE file_id = @file_id::uuid
)
-- Authorize Filter clause will be injected below in GetAuthorizedChatsByChatFileID.
-- @authorize_filter
;
-- name: AcquireStaleChatDiffStatuses :many
WITH acquired AS (
UPDATE
+12
View File
@@ -71,6 +71,18 @@ sql:
- column: "chats_expanded.labels"
go_type:
type: "StringMap"
- column: "chats.user_acl"
go_type:
type: "ChatACL"
- column: "chats.group_acl"
go_type:
type: "ChatACL"
- column: "chats_expanded.user_acl"
go_type:
type: "ChatACL"
- column: "chats_expanded.group_acl"
go_type:
type: "ChatACL"
- column: "users.rbac_roles"
go_type: "github.com/lib/pq.StringArray"
- column: "templates.user_acl"
+35
View File
@@ -80,6 +80,41 @@ func (t TemplateACL) Value() (driver.Value, error) {
return json.Marshal(t)
}
type ChatACL map[string]ChatACLEntry
func (c *ChatACL) Scan(src interface{}) error {
switch v := src.(type) {
case string:
return json.Unmarshal([]byte(v), &c)
case []byte:
return json.Unmarshal(v, &c)
case json.RawMessage:
return json.Unmarshal(v, &c)
}
return xerrors.Errorf("unexpected type %T", src)
}
//nolint:revive
func (c ChatACL) RBACACL() map[string][]policy.Action {
rbacACL := make(map[string][]policy.Action, len(c))
for id, entry := range c {
rbacACL[id] = entry.Permissions
}
return rbacACL
}
func (c ChatACL) Value() (driver.Value, error) {
if c == nil {
return json.Marshal(ChatACL{})
}
return json.Marshal(c)
}
type ChatACLEntry struct {
Permissions []policy.Action `json:"permissions"`
}
type WorkspaceACL map[string]WorkspaceACLEntry
func (t *WorkspaceACL) Scan(src interface{}) error {
+32 -9
View File
@@ -364,7 +364,8 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
}
params := database.GetChatsParams{
OwnerID: apiKey.UserID,
OwnedOnly: true,
ViewerID: apiKey.UserID,
Archived: searchParams.Archived,
AfterID: paginationParams.AfterID,
LabelFilter: labelFilter,
@@ -2576,6 +2577,11 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
aReq, commitAudit := audit.InitRequest[database.Chat](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
@@ -2891,7 +2897,7 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
// Sending a message triggers LLM inference, requiring update
// permission on the org-scoped chat resource.
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.Forbidden(rw)
httpapi.ResourceNotFound(rw)
return
}
@@ -3085,6 +3091,11 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
// Only the chat owner may edit messages. See postChatMessages
// for the security rationale.
if apiKey.UserID != chat.OwnerID {
@@ -3199,6 +3210,11 @@ func (api *API) deleteChatQueuedMessage(rw http.ResponseWriter, r *http.Request)
chat := httpmw.ChatParam(r)
chatID := chat.ID
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
queuedMessageIDStr := chi.URLParam(r, "queuedMessage")
queuedMessageID, err := strconv.ParseInt(queuedMessageIDStr, 10, 64)
if err != nil {
@@ -3238,7 +3254,7 @@ func (api *API) promoteChatQueuedMessage(rw http.ResponseWriter, r *http.Request
// Promoting a queued message triggers LLM inference,
// requiring update permission on the org-scoped chat resource.
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.Forbidden(rw)
httpapi.ResourceNotFound(rw)
return
}
@@ -3409,11 +3425,13 @@ func (api *API) streamChat(rw http.ResponseWriter, r *http.Request) {
go httpapi.HeartbeatClose(ctx, logger, cancel, conn)
// Mark the chat as read when the stream connects and again
// when it disconnects so we avoid per-message API calls while
// messages are actively streaming.
api.markChatAsRead(ctx, chatID)
defer api.markChatAsRead(context.WithoutCancel(ctx), chatID)
// The last_read_message_id field is owner-scoped. Shared readers
// intentionally lack chat update permission, so their streams must not
// update it.
if chat.OwnerID == httpmw.APIKey(r).UserID {
api.markChatAsRead(ctx, chatID)
defer api.markChatAsRead(context.WithoutCancel(ctx), chatID)
}
encoder := json.NewEncoder(wsNetConn)
@@ -3499,6 +3517,11 @@ func (api *API) interruptChat(rw http.ResponseWriter, r *http.Request) {
chatID := chat.ID
logger := api.Logger.Named("chat_interrupt").With(slog.F("chat_id", chatID))
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
if api.chatDaemon != nil {
chat = api.chatDaemon.InterruptChat(ctx, chat)
} else {
@@ -8113,7 +8136,7 @@ func (api *API) postChatToolResults(rw http.ResponseWriter, r *http.Request) {
// Submitting tool results resumes LLM inference,
// requiring update permission on the org-scoped chat resource.
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.Forbidden(rw)
httpapi.ResourceNotFound(rw)
return
}
+227
View File
@@ -14291,6 +14291,233 @@ func requireSDKError(t *testing.T, err error, expectedStatus int) *codersdk.Erro
return sdkErr
}
func TestChatReadOnlySharedWriteHandlers(t *testing.T) {
t.Parallel()
const sharedChatText = "read only shared chat"
setup := func(t *testing.T) (
ctx context.Context,
ownerClient *codersdk.ExperimentalClient,
sharedClient *codersdk.ExperimentalClient,
chat codersdk.Chat,
db database.Store,
) {
t.Helper()
ctx = testutil.Context(t, testutil.WaitLong)
ownerClient, db = newChatClientWithDatabase(t)
owner := coderdtest.CreateFirstUser(t, ownerClient.Client)
_ = createChatModelConfig(t, ownerClient)
sharedRaw, sharedUser := coderdtest.CreateAnotherUser(
t,
ownerClient.Client,
owner.OrganizationID,
rbac.ScopedRoleAgentsAccess(owner.OrganizationID),
)
sharedClient = codersdk.NewExperimentalClient(sharedRaw)
var err error
chat, err = ownerClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: owner.OrganizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: sharedChatText,
}},
})
require.NoError(t, err)
err = db.UpdateChatACLByID(dbauthz.As(ctx, rbac.Subject{
ID: owner.UserID.String(),
Roles: rbac.RoleIdentifiers{rbac.RoleOwner()},
Scope: rbac.ScopeAll,
}), database.UpdateChatACLByIDParams{
ID: chat.ID,
UserACL: database.ChatACL{
sharedUser.ID.String(): database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}},
},
GroupACL: database.ChatACL{},
})
require.NoError(t, err)
return ctx, ownerClient, sharedClient, chat, db
}
t.Run("GetChatAndMessages", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
gotChat, err := sharedClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, chat.ID, gotChat.ID)
messagesResult, err := sharedClient.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.NotEmpty(t, messagesResult.Messages)
foundUserMessage := false
for _, message := range messagesResult.Messages {
if message.Role != codersdk.ChatMessageRoleUser {
continue
}
for _, part := range message.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == sharedChatText {
foundUserMessage = true
break
}
}
}
require.True(t, foundUserMessage)
})
t.Run("PatchChat", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
err := sharedClient.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Archived: ptr.Ref(true),
})
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("PatchChatMessage", func(t *testing.T) {
t.Parallel()
ctx, ownerClient, sharedClient, chat, _ := setup(t)
messagesResult, err := ownerClient.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
var userMessageID int64
for _, msg := range messagesResult.Messages {
if msg.Role == codersdk.ChatMessageRoleUser {
userMessageID = msg.ID
break
}
}
require.NotZero(t, userMessageID)
_, err = sharedClient.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "read only user cannot edit",
}},
})
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("PostChatMessages", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
_, err := sharedClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "read only user cannot send messages",
}},
})
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("PromoteChatQueuedMessage", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, db := setup(t)
queuedContent, err := json.Marshal([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("queued"),
})
require.NoError(t, err)
queuedMessage, err := db.InsertChatQueuedMessage(
dbauthz.AsSystemRestricted(ctx),
database.InsertChatQueuedMessageParams{
ChatID: chat.ID,
Content: queuedContent,
},
)
require.NoError(t, err)
res, err := sharedClient.Request(
ctx,
http.MethodPost,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("PostChatToolResults", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
err := sharedClient.SubmitToolResults(ctx, chat.ID, codersdk.SubmitToolResultsRequest{
Results: []codersdk.ToolResult{{
ToolCallID: "call_read_only",
Output: json.RawMessage(`"forbidden"`),
}},
})
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("DeleteChatQueuedMessage", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, db := setup(t)
queuedContent, err := json.Marshal([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("queued"),
})
require.NoError(t, err)
queuedMessage, err := db.InsertChatQueuedMessage(
dbauthz.AsSystemRestricted(ctx),
database.InsertChatQueuedMessageParams{
ChatID: chat.ID,
Content: queuedContent,
},
)
require.NoError(t, err)
res, err := sharedClient.Request(
ctx,
http.MethodDelete,
fmt.Sprintf("/api/experimental/chats/%s/queue/%d", chat.ID, queuedMessage.ID),
nil,
)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("InterruptChat", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
_, err := sharedClient.InterruptChat(ctx, chat.ID)
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("RegenerateChatTitle", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
_, err := sharedClient.RegenerateChatTitle(ctx, chat.ID)
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("ProposeChatTitle", func(t *testing.T) {
t.Parallel()
ctx, _, sharedClient, chat, _ := setup(t)
_, err := sharedClient.ProposeChatTitle(ctx, chat.ID)
requireSDKError(t, err, http.StatusNotFound)
})
}
// TestChatOwnerOnlyWriteHandlers verifies that only the chat owner can
// call handlers that trigger chat processing. Org admins pass the RBAC
// ActionUpdate check (org-level permission) but must still be blocked
+7 -4
View File
@@ -708,12 +708,15 @@ func ConfigWithoutACL() regosql.ConvertConfig {
}
}
// ConfigChats is the configuration for converting rego to SQL when
// the target table is "chats", which has no ACL
// columns.
// ConfigChats uses a resource converter so SQL filters qualify chat
// ACL columns consistently with GetChats.
func ConfigChats() regosql.ConvertConfig {
converter := regosql.ChatConverter()
if ChatACLDisabled() {
converter = regosql.ChatNoACLConverter()
}
return regosql.ConvertConfig{
VariableConverter: regosql.NoACLConverter(),
VariableConverter: converter,
}
}
+14
View File
@@ -253,3 +253,17 @@ func SetWorkspaceACLDisabled(v bool) {
func WorkspaceACLDisabled() bool {
return workspaceACLDisabled.Load()
}
var chatACLDisabled atomic.Bool
// SetChatACLDisabled is global because database model methods build
// RBAC objects without API instance state.
func SetChatACLDisabled(v bool) {
chatACLDisabled.Store(v)
}
// ChatACLDisabled is global because database model methods build RBAC
// objects without API instance state.
func ChatACLDisabled() bool {
return chatACLDisabled.Load()
}
+1
View File
@@ -103,6 +103,7 @@ var (
// - "ActionCreate" :: create a new chat
// - "ActionDelete" :: delete a chat
// - "ActionRead" :: read chat messages and metadata
// - "ActionShare" :: share a chat with other users or groups
// - "ActionUpdate" :: update chat title or settings
ResourceChat = Object{
Type: "chat",
+1
View File
@@ -82,6 +82,7 @@ var chatActions = map[Action]ActionDefinition{
ActionRead: "read chat messages and metadata",
ActionUpdate: "update chat title or settings",
ActionDelete: "delete a chat",
ActionShare: "share a chat with other users or groups",
}
// RBACPermissions is indexed by the type
+10
View File
@@ -217,6 +217,16 @@ func TestRegoQueries(t *testing.T) {
" OR (workspaces.group_acl#>array['96c55a0e-73b4-44fc-abac-70d53c35c04c', 'permissions'] ? '*'))",
VariableConverter: regosql.WorkspaceConverter(),
},
{
Name: "UserChatACLAllow",
Queries: []string{
`"read" in input.object.acl_user_list["d5389ccc-57a4-4b13-8c3f-31747bcdc9f1"]`,
`"*" in input.object.acl_user_list["d5389ccc-57a4-4b13-8c3f-31747bcdc9f1"]`,
},
ExpectedSQL: "((chats_expanded.user_acl#>array['d5389ccc-57a4-4b13-8c3f-31747bcdc9f1', 'permissions'] ? 'read')" +
" OR (chats_expanded.user_acl#>array['d5389ccc-57a4-4b13-8c3f-31747bcdc9f1', 'permissions'] ? '*'))",
VariableConverter: regosql.ChatConverter(),
},
{
Name: "NoACLConfig",
Queries: []string{
+28
View File
@@ -50,6 +50,34 @@ func WorkspaceConverter() *sqltypes.VariableConverter {
return matcher
}
func ChatConverter() *sqltypes.VariableConverter {
matcher := chatBaseConverter()
matcher.RegisterMatcher(
ACLMappingMatcher(matcher, "chats_expanded.group_acl", []string{"input", "object", "acl_group_list"}).UsingSubfield("permissions"),
ACLMappingMatcher(matcher, "chats_expanded.user_acl", []string{"input", "object", "acl_user_list"}).UsingSubfield("permissions"),
)
return matcher
}
func ChatNoACLConverter() *sqltypes.VariableConverter {
matcher := chatBaseConverter()
matcher.RegisterMatcher(
sqltypes.AlwaysFalse(groupACLMatcher(matcher)),
sqltypes.AlwaysFalse(userACLMatcher(matcher)),
)
return matcher
}
func chatBaseConverter() *sqltypes.VariableConverter {
return sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
sqltypes.StringVarMatcher("chats_expanded.organization_id :: text", []string{"input", "object", "org_owner"}),
userOwnerMatcher(),
)
}
func AuditLogConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
+11 -4
View File
@@ -243,6 +243,7 @@ var builtInRoles map[string]func(orgID uuid.UUID) Role
type RoleOptions struct {
NoOwnerWorkspaceExec bool
NoWorkspaceSharing bool
NoChatSharing bool
}
// ReservedRoleName exists because the database should only allow unique role
@@ -272,6 +273,13 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Action: policy.ActionShare,
})
}
if opts.NoChatSharing {
denyPermissions = append(denyPermissions, Permission{
Negate: true,
ResourceType: ResourceChat.Type,
Action: policy.ActionShare,
})
}
ownerWorkspaceActions := ResourceWorkspace.AvailableActions()
if opts.NoOwnerWorkspaceExec {
@@ -589,10 +597,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
},
}
},
// agentsAccess grants org members permission to create, read, and
// update chats. ActionDelete is intentionally excluded: no dbauthz
// function checks it on ResourceChat. Hard-deletion goes through
// ResourceSystem (dbpurge).
// ActionDelete is intentionally excluded because hard-deletion goes through
// ResourceSystem in dbpurge.
agentsAccess: func(organizationID uuid.UUID) Role {
return Role{
Identifier: RoleIdentifier{Name: agentsAccess, OrganizationID: organizationID},
@@ -606,6 +612,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ResourceChat.Type: {
policy.ActionCreate,
policy.ActionRead,
policy.ActionShare,
policy.ActionUpdate,
},
}),
+61
View File
@@ -115,6 +115,58 @@ func TestOrgSharingPermissions(t *testing.T) {
}
}
//nolint:tparallel,paralleltest
func TestChatSharingPermissions(t *testing.T) {
target := rbac.Permission{
Negate: true,
ResourceType: rbac.ResourceChat.Type,
Action: policy.ActionShare,
}
orgID := uuid.New()
userID := uuid.NewString()
resource := rbac.ResourceChat.WithID(uuid.New()).InOrg(orgID).WithOwner(userID)
authorizeAgentsAccessUser := func(t *testing.T) error {
t.Helper()
memberRole, err := rbac.RoleByName(rbac.RoleMember())
require.NoError(t, err)
agentsRole, err := rbac.RoleByName(rbac.ScopedRoleAgentsAccess(orgID))
require.NoError(t, err)
auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry())
return auth.Authorize(context.Background(), rbac.Subject{
ID: userID,
Roles: rbac.Roles{memberRole, agentsRole},
Scope: rbac.ScopeAll,
}, policy.ActionShare, resource)
}
t.Run("Default", func(t *testing.T) {
rbac.ReloadBuiltinRoles(nil)
t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) })
memberRole, err := rbac.RoleByName(rbac.RoleMember())
require.NoError(t, err)
assert.False(t, permissionGranted(memberRole.Site, target))
require.NoError(t, authorizeAgentsAccessUser(t))
})
t.Run("Disabled", func(t *testing.T) {
rbac.ReloadBuiltinRoles(&rbac.RoleOptions{
NoChatSharing: true,
})
t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) })
memberRole, err := rbac.RoleByName(rbac.RoleMember())
require.NoError(t, err)
assert.True(t, permissionGranted(memberRole.Site, target))
err = authorizeAgentsAccessUser(t)
require.ErrorAs(t, err, &rbac.UnauthorizedError{})
})
}
//nolint:tparallel,paralleltest
func TestOwnerExec(t *testing.T) {
owner := rbac.Subject{
@@ -1158,6 +1210,15 @@ func TestRolePermissions(t *testing.T) {
false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
},
},
{
Name: "ChatUsageShare",
Actions: []policy.Action{policy.ActionShare},
Resource: rbac.ResourceChat.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, agentsAccessUser},
false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
},
},
{
Name: "ChatUsageDelete",
Actions: []policy.Action{policy.ActionDelete},
+3
View File
@@ -39,6 +39,7 @@ const (
ScopeChatCreate ScopeName = "chat:create"
ScopeChatDelete ScopeName = "chat:delete"
ScopeChatRead ScopeName = "chat:read"
ScopeChatShare ScopeName = "chat:share"
ScopeChatUpdate ScopeName = "chat:update"
ScopeConnectionLogRead ScopeName = "connection_log:read"
ScopeConnectionLogUpdate ScopeName = "connection_log:update"
@@ -211,6 +212,7 @@ func (e ScopeName) Valid() bool {
ScopeChatCreate,
ScopeChatDelete,
ScopeChatRead,
ScopeChatShare,
ScopeChatUpdate,
ScopeConnectionLogRead,
ScopeConnectionLogUpdate,
@@ -384,6 +386,7 @@ func AllScopeNameValues() []ScopeName {
ScopeChatCreate,
ScopeChatDelete,
ScopeChatRead,
ScopeChatShare,
ScopeChatUpdate,
ScopeConnectionLogRead,
ScopeConnectionLogUpdate,
@@ -12,6 +12,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/testutil"
)
@@ -74,3 +75,85 @@ func TestActiveAgentChatDefinitionsAgree(t *testing.T) {
)
}
}
func TestActiveAgentChatsIncludeInheritedACLs(t *testing.T) {
t.Parallel()
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitMedium))
db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
org, err := db.GetDefaultOrganization(ctx)
require.NoError(t, err)
owner := dbgen.User(t, db, database.User{})
workspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: owner.ID,
}).WithAgent().Do()
modelConfig := insertAgentChatTestModelConfig(t, db, owner.ID)
root, err := db.InsertChat(ctx, database.InsertChatParams{
OrganizationID: org.ID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: owner.ID,
LastModelConfigID: modelConfig.ID,
Title: "root-active-chat",
AgentID: uuid.NullUUID{UUID: workspace.Agents[0].ID, Valid: true},
})
require.NoError(t, err)
child, err := db.InsertChat(ctx, database.InsertChatParams{
OrganizationID: org.ID,
Status: database.ChatStatusRunning,
ClientType: database.ChatClientTypeUi,
OwnerID: owner.ID,
LastModelConfigID: modelConfig.ID,
Title: "child-active-chat",
AgentID: uuid.NullUUID{UUID: workspace.Agents[0].ID, Valid: true},
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
})
require.NoError(t, err)
rootUserACL := database.ChatACL{
owner.ID.String(): {Permissions: []policy.Action{policy.ActionRead, policy.ActionSSH}},
}
rootGroupACL := database.ChatACL{
org.ID.String(): {Permissions: []policy.Action{policy.ActionRead}},
}
userACLValue, err := rootUserACL.Value()
require.NoError(t, err)
groupACLValue, err := rootGroupACL.Value()
require.NoError(t, err)
_, err = sqlDB.ExecContext(
ctx,
`UPDATE chats SET user_acl = $1::jsonb, group_acl = $2::jsonb WHERE id = $3`,
userACLValue,
groupACLValue,
root.ID,
)
require.NoError(t, err)
activeChats, err := db.GetActiveChatsByAgentID(ctx, workspace.Agents[0].ID)
require.NoError(t, err)
require.Len(t, activeChats, 2)
activeByID := make(map[uuid.UUID]database.Chat, len(activeChats))
for _, chat := range activeChats {
activeByID[chat.ID] = chat
}
fetchedRoot, ok := activeByID[root.ID]
require.True(t, ok)
require.Equal(t, rootUserACL, fetchedRoot.UserACL)
require.Equal(t, rootGroupACL, fetchedRoot.GroupACL)
fetchedChild, ok := activeByID[child.ID]
require.True(t, ok)
require.True(t, fetchedChild.ParentChatID.Valid)
require.Equal(t, rootUserACL, fetchedChild.UserACL)
require.Equal(t, rootGroupACL, fetchedChild.GroupACL)
}
+8 -3
View File
@@ -721,7 +721,10 @@ func TestExploreSubagentIsReadOnly(t *testing.T) {
require.True(t, requestHasSystemSubstring(childRequests[0], "You are in Explore Mode as a delegated sub-agent."))
require.False(t, requestHasSystemSubstring(rootRequests[0], "You are in Explore Mode as a delegated sub-agent."))
rootChats, err := db.GetChats(dbauthz.AsChatd(ctx), database.GetChatsParams{OwnerID: user.UserID})
rootChats, err := db.GetChats(dbauthz.AsChatd(ctx), database.GetChatsParams{
OwnedOnly: true,
ViewerID: user.UserID,
})
require.NoError(t, err)
rootIDs := make([]uuid.UUID, 0, len(rootChats))
for _, root := range rootChats {
@@ -2362,7 +2365,8 @@ func TestCreateChatRejectsWhenUsageLimitReached(t *testing.T) {
})
beforeChats, err := db.GetChats(ctx, database.GetChatsParams{
OwnerID: user.ID,
OwnedOnly: true,
ViewerID: user.ID,
AfterID: uuid.Nil,
OffsetOpt: 0,
LimitOpt: 100,
@@ -2385,7 +2389,8 @@ func TestCreateChatRejectsWhenUsageLimitReached(t *testing.T) {
require.Equal(t, int64(100), limitErr.ConsumedMicros)
afterChats, err := db.GetChats(ctx, database.GetChatsParams{
OwnerID: user.ID,
OwnedOnly: true,
ViewerID: user.ID,
AfterID: uuid.Nil,
OffsetOpt: 0,
LimitOpt: 100,
+4 -2
View File
@@ -2005,7 +2005,8 @@ func TestSpawnAgent_ComputerUseRejectsMissingConfiguredProvider(t *testing.T) {
ids := availableSubagentTypeIDs(ctx, server, parentChat)
require.Contains(t, ids, subagentTypeComputerUse)
beforeChats, err := db.GetChats(ctx, database.GetChatsParams{
OwnerID: user.ID,
OwnedOnly: true,
ViewerID: user.ID,
AfterID: uuid.Nil,
OffsetOpt: 0,
LimitOpt: 100,
@@ -2021,7 +2022,8 @@ func TestSpawnAgent_ComputerUseRejectsMissingConfiguredProvider(t *testing.T) {
require.Contains(t, resp.Content, "computer-use")
require.Contains(t, resp.Content, "openai")
afterChats, err := db.GetChats(ctx, database.GetChatsParams{
OwnerID: user.ID,
OwnedOnly: true,
ViewerID: user.ID,
AfterID: uuid.Nil,
OffsetOpt: 0,
LimitOpt: 100,