From 170a6e1fe915ff71bebb2d984d74dfbf4c43d16e Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 18 May 2026 22:32:05 +0100 Subject: [PATCH] feat: add chat sharing foundation (#25041) --- cli/testdata/coder_server_--help.golden | 4 + cli/testdata/server-config.yaml.golden | 4 + coderd/apidoc/docs.go | 5 + coderd/apidoc/swagger.json | 5 + coderd/coderd.go | 6 +- coderd/database/check_constraint.go | 3 + coderd/database/dbauthz/dbauthz.go | 74 +++- coderd/database/dbauthz/dbauthz_test.go | 151 +++++++- coderd/database/dbmetrics/querymetrics.go | 32 ++ coderd/database/dbmock/dbmock.go | 59 ++++ coderd/database/dbpurge/dbpurge.go | 21 ++ coderd/database/dump.sql | 13 +- .../000501_chat_acl_sharing.down.sql | 45 +++ .../migrations/000501_chat_acl_sharing.up.sql | 60 ++++ coderd/database/modelmethods.go | 17 +- coderd/database/modelmethods_internal_test.go | 39 +++ coderd/database/modelqueries.go | 80 ++++- coderd/database/models.go | 9 +- coderd/database/querier.go | 3 + coderd/database/querier_test.go | 326 +++++++++++++++++- coderd/database/queries.sql.go | 325 ++++++++++++++--- coderd/database/queries/chats.sql | 90 ++++- coderd/database/sqlc.yaml | 12 + coderd/database/types.go | 35 ++ coderd/exp_chats.go | 41 ++- coderd/exp_chats_test.go | 227 ++++++++++++ coderd/rbac/authz.go | 11 +- coderd/rbac/object.go | 14 + coderd/rbac/object_gen.go | 1 + coderd/rbac/policy/policy.go | 1 + coderd/rbac/regosql/compile_test.go | 10 + coderd/rbac/regosql/configs.go | 28 ++ coderd/rbac/roles.go | 15 +- coderd/rbac/roles_test.go | 61 ++++ coderd/rbac/scopes_constants_gen.go | 3 + ...rkspaceagents_active_chat_internal_test.go | 83 +++++ coderd/x/chatd/chatd_test.go | 11 +- coderd/x/chatd/subagent_internal_test.go | 6 +- codersdk/apikey_scopes_gen.go | 1 + codersdk/deployment.go | 10 + codersdk/rbacresources_gen.go | 2 +- docs/admin/security/audit-logs.md | 2 +- docs/reference/api/general.md | 1 + docs/reference/api/schemas.md | 9 +- docs/reference/cli/server.md | 10 + enterprise/audit/table.go | 2 + .../cli/testdata/coder_server_--help.golden | 4 + site/src/api/rbacresourcesGenerated.ts | 1 + site/src/api/typesGenerated.ts | 3 + 49 files changed, 1872 insertions(+), 103 deletions(-) create mode 100644 coderd/database/migrations/000501_chat_acl_sharing.down.sql create mode 100644 coderd/database/migrations/000501_chat_acl_sharing.up.sql diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 13a3c5614a..90a643cc2d 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -36,6 +36,10 @@ OPTIONS: creating a token without specifying a duration, such as when authenticating the CLI or an IDE plugin. + --disable-chat-sharing bool, $CODER_DISABLE_CHAT_SHARING + Disable chat sharing. Chat ACL checking is disabled and only owners + can access their chats. + --disable-owner-workspace-access bool, $CODER_DISABLE_OWNER_WORKSPACE_ACCESS Remove the permission for the 'owner' role to have workspace execution on all workspaces. This prevents the 'owner' from ssh, apps, and diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index cfbb6ba1b3..81d706e32e 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -530,6 +530,10 @@ disableOwnerWorkspaceAccess: false # --disable-owner-workspace-access. # (default: , type: bool) disableWorkspaceSharing: false +# Disable chat sharing. Chat ACL checking is disabled and only owners can access +# their chats. +# (default: , type: bool) +disableChatSharing: false # These options change the behavior of how clients interact with the Coder. # Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. client: diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b3ec4392a3..40201f7263 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a3416d9ce3..3074103b83 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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" }, diff --git a/coderd/coderd.go b/coderd/coderd.go index dd67488264..0c2c84bf93 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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() diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 90750642cd..993333e416 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -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 diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index fd1d6c5ff4..1ffa818dde 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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) +} diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index baf4bfe18a..d93e735348 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index aeae4294c9..9233857fce 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -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 +} diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 09d751c88d..3ab878dc01 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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() diff --git a/coderd/database/dbpurge/dbpurge.go b/coderd/database/dbpurge/dbpurge.go index ac98e0ddbf..a18f08609c 100644 --- a/coderd/database/dbpurge/dbpurge.go +++ b/coderd/database/dbpurge/dbpurge.go @@ -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, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f30b6caa73..7b4ce53fac 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -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 ( diff --git a/coderd/database/migrations/000501_chat_acl_sharing.down.sql b/coderd/database/migrations/000501_chat_acl_sharing.down.sql new file mode 100644 index 0000000000..689ccbc5ba --- /dev/null +++ b/coderd/database/migrations/000501_chat_acl_sharing.down.sql @@ -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. diff --git a/coderd/database/migrations/000501_chat_acl_sharing.up.sql b/coderd/database/migrations/000501_chat_acl_sharing.up.sql new file mode 100644 index 0000000000..c8a6cb4026 --- /dev/null +++ b/coderd/database/migrations/000501_chat_acl_sharing.up.sql @@ -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'; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 308a33baf0..ced4716c5c 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -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 { diff --git a/coderd/database/modelmethods_internal_test.go b/coderd/database/modelmethods_internal_test.go index 27cbd916fa..090e1141b2 100644 --- a/coderd/database/modelmethods_internal_test.go +++ b/coderd/database/modelmethods_internal_test.go @@ -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() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 408ac7a43b..76f1934c62 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -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) diff --git a/coderd/database/models.go b/coderd/database/models.go index 7af3e2b5cd..753597e5bb 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -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 { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 9366d2316e..9cf04de180 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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 diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 1d0954f1e7..4e7a702b4f 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -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 { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5de7ed842d..0e5499910a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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, ) diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index c327cc6a4f..329a819881 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -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 diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 2a4787f6ea..2b984d4c7e 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -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" diff --git a/coderd/database/types.go b/coderd/database/types.go index 6d68a19bda..e0ab43b9ff 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -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 { diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index ea27c3c2ea..9ad3bd863c 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -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 } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 77ad68acb6..4f47e23c6a 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -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 diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 78684f35ec..865264c22e 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -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, } } diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index a3f4b5d740..2994a8bfd9 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -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() +} diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 2d304e6a3e..d5d5f821b1 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -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", diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index ccbc7634db..f6c0ec4a5b 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -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 diff --git a/coderd/rbac/regosql/compile_test.go b/coderd/rbac/regosql/compile_test.go index e42e3f70e8..bf598fe02e 100644 --- a/coderd/rbac/regosql/compile_test.go +++ b/coderd/rbac/regosql/compile_test.go @@ -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{ diff --git a/coderd/rbac/regosql/configs.go b/coderd/rbac/regosql/configs.go index 22302a5296..027f0adeb8 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -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(), diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index c9dc94c300..3ff972b855 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -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, }, }), diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 9cbefced0e..061a061801 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -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}, diff --git a/coderd/rbac/scopes_constants_gen.go b/coderd/rbac/scopes_constants_gen.go index cc1a75d1f6..d2021968cf 100644 --- a/coderd/rbac/scopes_constants_gen.go +++ b/coderd/rbac/scopes_constants_gen.go @@ -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, diff --git a/coderd/workspaceagents_active_chat_internal_test.go b/coderd/workspaceagents_active_chat_internal_test.go index c2d8291f8e..24e833f09c 100644 --- a/coderd/workspaceagents_active_chat_internal_test.go +++ b/coderd/workspaceagents_active_chat_internal_test.go @@ -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) +} diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 88504f4aed..b67a08b689 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -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, diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index 3e979e1d44..fffea3bc78 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -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, diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index 442601e40b..d13a58c687 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -48,6 +48,7 @@ const ( APIKeyScopeChatCreate APIKeyScope = "chat:create" APIKeyScopeChatDelete APIKeyScope = "chat:delete" APIKeyScopeChatRead APIKeyScope = "chat:read" + APIKeyScopeChatShare APIKeyScope = "chat:share" APIKeyScopeChatUpdate APIKeyScope = "chat:update" APIKeyScopeCoderAll APIKeyScope = "coder:all" APIKeyScopeCoderApikeysManageSelf APIKeyScope = "coder:apikeys.manage_self" diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 19d2cb22fd..cc44c6bd68 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -657,6 +657,7 @@ type DeploymentValues struct { WgtunnelHost serpent.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` DisableOwnerWorkspaceExec serpent.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` DisableWorkspaceSharing serpent.Bool `json:"disable_workspace_sharing,omitempty" typescript:",notnull"` + DisableChatSharing serpent.Bool `json:"disable_chat_sharing,omitempty" typescript:",notnull"` ProxyHealthStatusInterval serpent.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` EnableTerraformDebugMode serpent.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` @@ -3102,6 +3103,15 @@ func (c *DeploymentValues) Options() serpent.OptionSet { Value: &c.DisableWorkspaceSharing, YAML: "disableWorkspaceSharing", }, + { + Name: "Disable Chat Sharing", + Description: "Disable chat sharing. Chat ACL checking is disabled and only owners can access their chats.", + Flag: "disable-chat-sharing", + Env: "CODER_DISABLE_CHAT_SHARING", + + Value: &c.DisableChatSharing, + YAML: "disableChatSharing", + }, { Name: "Session Duration", Description: "The token expiry duration for browser sessions. Sessions may last longer if they are actively making requests, but this functionality can be disabled via --disable-session-expiry-refresh.", diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index f5940f6ad6..5bf3a4f191 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -89,7 +89,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceAssignRole: {ActionAssign, ActionRead, ActionUnassign}, ResourceAuditLog: {ActionCreate, ActionRead}, ResourceBoundaryUsage: {ActionDelete, ActionRead, ActionUpdate}, - ResourceChat: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceChat: {ActionCreate, ActionDelete, ActionRead, ActionShare, ActionUpdate}, ResourceConnectionLog: {ActionRead, ActionUpdate}, ResourceCryptoKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceDebugInfo: {ActionRead}, diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 9234d87982..9535ca042d 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -23,7 +23,7 @@ We track the following resources: | Group
create, write, delete | |
FieldTracked
avatar_urltrue
chat_spend_limit_microstrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| | AuditableGroupAiBudget
write, delete | |
FieldTracked
created_atfalse
group_idfalse
group_namefalse
spend_limittrue
spend_limit_microsfalse
updated_atfalse
| | AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| -| Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
created_atfalse
dynamic_toolsfalse
heartbeat_atfalse
idtrue
labelstrue
last_errorfalse
last_injected_contextfalse
last_model_config_idfalse
last_read_message_idfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
root_chat_idfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
worker_idfalse
workspace_idtrue
| +| Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
created_atfalse
dynamic_toolsfalse
group_acltrue
heartbeat_atfalse
idtrue
labelstrue
last_errorfalse
last_injected_contextfalse
last_model_config_idfalse
last_read_message_idfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
root_chat_idfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
user_acltrue
worker_idfalse
workspace_idtrue
| | CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| | GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| | GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index e7a30d1e69..57e564aee3 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -275,6 +275,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ ] } }, + "disable_chat_sharing": true, "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 436395a88d..15499188df 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1400,9 +1400,9 @@ #### Enumerated Values -| Value(s) | -|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | +| Value(s) | +|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | ## codersdk.AddLicenseRequest @@ -5426,6 +5426,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ] } }, + "disable_chat_sharing": true, "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, @@ -6024,6 +6025,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ] } }, + "disable_chat_sharing": true, "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, @@ -6409,6 +6411,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `config_ssh` | [codersdk.SSHConfig](#codersdksshconfig) | false | | | | `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | | | `derp` | [codersdk.DERP](#codersdkderp) | false | | | +| `disable_chat_sharing` | boolean | false | | | | `disable_owner_workspace_exec` | boolean | false | | | | `disable_password_auth` | boolean | false | | | | `disable_path_apps` | boolean | false | | | diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 20145a509f..92af3a3c9d 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1169,6 +1169,16 @@ Remove the permission for the 'owner' role to have workspace execution on all wo Disable workspace sharing. Workspace ACL checking is disabled and only owners can have ssh, apps and terminal access to workspaces. Access based on the 'owner' role is also allowed unless disabled via --disable-owner-workspace-access. +### --disable-chat-sharing + +| | | +|-------------|------------------------------------------| +| Type | bool | +| Environment | $CODER_DISABLE_CHAT_SHARING | +| YAML | disableChatSharing | + +Disable chat sharing. Chat ACL checking is disabled and only owners can access their chats. + ### --session-duration | | | diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 2bdf20820a..9475f6e118 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -437,6 +437,8 @@ var auditableResourcesTypes = map[any]map[string]Action{ "mode": ActionTrack, "mcp_server_ids": ActionTrack, "labels": ActionTrack, + "user_acl": ActionTrack, + "group_acl": ActionTrack, "pin_order": ActionTrack, "last_read_message_id": ActionIgnore, // User-scoped read cursor. "last_injected_context": ActionIgnore, // Internal lifecycle. diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index e5362ecf4f..82207075a6 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -37,6 +37,10 @@ OPTIONS: creating a token without specifying a duration, such as when authenticating the CLI or an IDE plugin. + --disable-chat-sharing bool, $CODER_DISABLE_CHAT_SHARING + Disable chat sharing. Chat ACL checking is disabled and only owners + can access their chats. + --disable-owner-workspace-access bool, $CODER_DISABLE_OWNER_WORKSPACE_ACCESS Remove the permission for the 'owner' role to have workspace execution on all workspaces. This prevents the 'owner' from ssh, apps, and diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 47942e70f6..1ee44290d6 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -59,6 +59,7 @@ export const RBACResourceActions: Partial< create: "create a new chat", delete: "delete a chat", read: "read chat messages and metadata", + share: "share a chat with other users or groups", update: "update chat title or settings", }, connection_log: { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bf759bc41b..d374a6fd2f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -521,6 +521,7 @@ export type APIKeyScope = | "chat:create" | "chat:delete" | "chat:read" + | "chat:share" | "chat:update" | "coder:all" | "coder:apikeys.manage_self" @@ -741,6 +742,7 @@ export const APIKeyScopes: APIKeyScope[] = [ "chat:create", "chat:delete", "chat:read", + "chat:share", "chat:update", "coder:all", "coder:apikeys.manage_self", @@ -3991,6 +3993,7 @@ export interface DeploymentValues { readonly wgtunnel_host?: string; readonly disable_owner_workspace_exec?: boolean; readonly disable_workspace_sharing?: boolean; + readonly disable_chat_sharing?: boolean; readonly proxy_health_status_interval?: number; readonly enable_terraform_debug_mode?: boolean; readonly user_quiet_hours_schedule?: UserQuietHoursScheduleConfig;