diff --git a/coderd/database/migrations/000538_chat_shared_notification.down.sql b/coderd/database/migrations/000538_chat_shared_notification.down.sql new file mode 100644 index 00000000000..716f7dc4e2f --- /dev/null +++ b/coderd/database/migrations/000538_chat_shared_notification.down.sql @@ -0,0 +1 @@ +DELETE FROM notification_templates WHERE id = 'b789bd75-d7c6-4cab-9757-1147ab184903'; diff --git a/coderd/database/migrations/000538_chat_shared_notification.up.sql b/coderd/database/migrations/000538_chat_shared_notification.up.sql new file mode 100644 index 00000000000..630e20b1233 --- /dev/null +++ b/coderd/database/migrations/000538_chat_shared_notification.up.sql @@ -0,0 +1,27 @@ +INSERT INTO notification_templates ( + id, + name, + title_template, + body_template, + actions, + "group", + method, + kind, + enabled_by_default +) +VALUES ( + 'b789bd75-d7c6-4cab-9757-1147ab184903', + 'Chat Shared', + E'{{.Labels.initiator}} shared a chat with you', + E'{{.Labels.initiator}} shared the chat "**{{.Labels.chat_title}}**" with you.', + '[ + { + "label": "View chat", + "url": "{{base_url}}/agents/{{.Labels.chat_id}}" + } + ]'::jsonb, + 'Chat Events', + NULL, + 'system'::notification_template_kind, + true +); diff --git a/coderd/exp_chats_acl.go b/coderd/exp_chats_acl.go index cb9af92f3d8..889cd933745 100644 --- a/coderd/exp_chats_acl.go +++ b/coderd/exp_chats_acl.go @@ -3,7 +3,10 @@ package coderd import ( "context" "database/sql" + "errors" + "maps" "net/http" + "slices" "github.com/google/uuid" "golang.org/x/xerrors" @@ -15,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/acl" "github.com/coder/coder/v2/coderd/rbac/policy" @@ -145,6 +149,7 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { return } + var oldChat database.Chat err := api.Database.InTx(func(tx database.Store) error { current, err := tx.GetChatByIDForUpdate(ctx, chat.ID) if err != nil { @@ -156,6 +161,8 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { if current.GroupACL == nil { current.GroupACL = database.ChatACL{} } + oldChat = current + oldChat.UserACL = maps.Clone(current.UserACL) for id, role := range req.UserRoles { if role == codersdk.ChatRoleDeleted { @@ -199,9 +206,70 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { return } + initiator, err := api.Database.GetUserByID(ctx, apiKey.UserID) + if err != nil { + api.Logger.Warn(ctx, "failed to load chat share initiator", slog.Error(err), slog.F("chat_id", chat.ID)) + } else { + newChat := aReq.New + go func() { + if count, err := api.notifyChatShared(oldChat, newChat, initiator); err != nil { + api.Logger.Warn(api.ctx, "failed to enqueue one or more chat shared notifications", slog.Error(err), slog.F("chat_id", newChat.ID), slog.F("attempted_recipients", count)) + } + }() + } + rw.WriteHeader(http.StatusNoContent) } +func (api *API) notifyChatShared(oldChat database.Chat, newChat database.Chat, initiator database.User) (int, error) { + oldReaders := api.directChatReaders(oldChat) + newReaders := api.directChatReaders(newChat) + + added, _ := slice.SymmetricDifference(oldReaders, newReaders) + recipientIDs := make([]uuid.UUID, 0, len(added)) + for _, userID := range added { + if userID == initiator.ID { + continue + } + recipientIDs = append(recipientIDs, userID) + } + if len(recipientIDs) == 0 { + return 0, nil + } + + labels := map[string]string{ + "chat_id": newChat.ID.String(), + "chat_title": newChat.Title, + "initiator": initiator.Username, + } + + //nolint:gocritic // Notifier actor is required to enqueue notifications. + notifierCtx := dbauthz.AsNotifier(api.ctx) + var errs []error + for _, userID := range recipientIDs { + if _, err := api.NotificationsEnqueuer.Enqueue(notifierCtx, userID, notifications.TemplateChatShared, labels, initiator.ID.String(), newChat.ID); err != nil { + errs = append(errs, xerrors.Errorf("enqueue chat shared notification: %w", err)) + } + } + return len(recipientIDs), errors.Join(errs...) +} + +func (api *API) directChatReaders(chat database.Chat) []uuid.UUID { + readers := []uuid.UUID{chat.OwnerID} + for rawUserID, entry := range chat.UserACL { + if !slices.Contains(entry.Permissions, policy.ActionRead) { + continue + } + userID, err := uuid.Parse(rawUserID) + if err != nil { + api.Logger.Warn(api.ctx, "skip chat ACL entry with invalid user UUID", slog.F("chat_id", chat.ID), slog.F("user_id", rawUserID), slog.Error(err)) + continue + } + readers = append(readers, userID) + } + return slice.Unique(readers) +} + func (api *API) chatACLUsers(ctx context.Context, rw http.ResponseWriter, chat database.Chat, entries database.ChatACL) ([]codersdk.ChatUser, bool) { userIDs := make([]uuid.UUID, 0, len(entries)) for userID := range entries { diff --git a/coderd/exp_chats_acl_test.go b/coderd/exp_chats_acl_test.go index c2a4cc29faa..ab109e473eb 100644 --- a/coderd/exp_chats_acl_test.go +++ b/coderd/exp_chats_acl_test.go @@ -16,6 +16,8 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/util/ptr" @@ -28,8 +30,10 @@ func TestChatACLSharingLifecycle(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) mAudit := audit.NewMock() + notifyEnq := ¬ificationstest.FakeEnqueuer{} client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) { opts.Auditor = mAudit + opts.NotificationsEnqueuer = notifyEnq }) firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModelConfig(t, client) @@ -68,6 +72,36 @@ func TestChatACLSharingLifecycle(t *testing.T) { ResourceID: chat.ID, UserID: firstUser.UserID, })) + // Only the direct user-ACL grant is notified. The group member gains + // access but is not notified, because group grants are not expanded. + var sent []*notificationstest.FakeNotification + testutil.Eventually(ctx, t, func(context.Context) bool { + sent = notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared)) + return len(sent) == 1 + }, testutil.IntervalFast) + require.Equal(t, sharedUser.ID, sent[0].UserID) + require.Equal(t, firstUser.UserID.String(), sent[0].CreatedBy) + require.Equal(t, map[string]string{ + "chat_id": chat.ID.String(), + "chat_title": chat.Title, + "initiator": coderdtest.FirstUserParams.Username, + }, sent[0].Labels) + require.Equal(t, []uuid.UUID{chat.ID}, sent[0].Targets) + for _, notification := range sent { + require.NotEqual(t, groupMember.ID, notification.UserID) + } + + notifyEnq.Clear() + err = client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{ + UserRoles: map[string]codersdk.ChatRole{ + sharedUser.ID.String(): codersdk.ChatRoleRead, + }, + GroupRoles: map[string]codersdk.ChatRole{ + sharedGroup.ID.String(): codersdk.ChatRoleRead, + }, + }) + require.NoError(t, err) + require.Empty(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared))) acl, err := client.GetChatACL(ctx, chat.ID) require.NoError(t, err) @@ -156,6 +190,7 @@ func TestChatACLSharingLifecycle(t *testing.T) { }, }) require.NoError(t, err) + require.Empty(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared))) _, err = sharedClientExp.GetChat(ctx, chat.ID) requireSDKError(t, err, http.StatusNotFound) _, err = groupMemberClientExp.GetChat(ctx, chat.ID) @@ -168,6 +203,7 @@ func TestChatACLSharingLifecycle(t *testing.T) { }, }) require.NoError(t, err) + require.Empty(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared))) require.True(t, mAudit.Contains(t, database.AuditLog{ Action: database.AuditActionWrite, ResourceType: database.ResourceTypeChat, @@ -178,6 +214,57 @@ func TestChatACLSharingLifecycle(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) } +func TestChatACLSharingNotifiesDirectReadersOnly(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + notifyEnq := ¬ificationstest.FakeEnqueuer{} + client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) { + opts.NotificationsEnqueuer = notifyEnq + }) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + // A non-owner org admin can share another user's chat via ActionShare. + adminClient, admin := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID)) + adminExp := codersdk.NewExperimentalClient(adminClient) + _, groupMember := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) + _, directUser := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) + + // The group contains both the sharing initiator and another member. + group := dbgen.Group(t, db, database.Group{OrganizationID: firstUser.OrganizationID}) + dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: group.ID, UserID: admin.ID}) + dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: group.ID, UserID: groupMember.ID}) + + chat := createChatForSharing(ctx, t, client, firstUser.OrganizationID, "admin shared chat") + + // Share with the group (which grants access to groupMember) and with + // directUser via the user ACL. Only directUser should be notified. + err := adminExp.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{ + UserRoles: map[string]codersdk.ChatRole{ + directUser.ID.String(): codersdk.ChatRoleRead, + }, + GroupRoles: map[string]codersdk.ChatRole{ + group.ID.String(): codersdk.ChatRoleRead, + }, + }) + require.NoError(t, err) + + var sent []*notificationstest.FakeNotification + testutil.Eventually(ctx, t, func(context.Context) bool { + sent = notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared)) + return len(sent) == 1 + }, testutil.IntervalFast) + // Only the direct user-ACL grant is notified. The group member, initiator + // (admin), and owner (firstUser) are never notified. + require.Equal(t, directUser.ID, sent[0].UserID) + for _, notification := range sent { + require.NotEqual(t, groupMember.ID, notification.UserID) + require.NotEqual(t, admin.ID, notification.UserID) + require.NotEqual(t, firstUser.UserID, notification.UserID) + } +} + func TestChatACLSubChatInheritance(t *testing.T) { t.Parallel() diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go index c18748fb9d8..42a22c5091b 100644 --- a/coderd/inboxnotifications.go +++ b/coderd/inboxnotifications.go @@ -58,6 +58,7 @@ var fallbackIcons = map[uuid.UUID]string{ // chat related notifications notifications.TemplateChatAutoArchiveDigest: codersdk.InboxNotificationFallbackIconOther, + notifications.TemplateChatShared: codersdk.InboxNotificationFallbackIconOther, } func ensureNotificationIcon(notif codersdk.InboxNotification) codersdk.InboxNotification { diff --git a/coderd/inboxnotifications_internal_test.go b/coderd/inboxnotifications_internal_test.go index c99d376bb77..ffbbe5f40a4 100644 --- a/coderd/inboxnotifications_internal_test.go +++ b/coderd/inboxnotifications_internal_test.go @@ -23,6 +23,7 @@ func TestInboxNotifications_ensureNotificationIcon(t *testing.T) { {"WorkspaceCreated", "", notifications.TemplateWorkspaceCreated, codersdk.InboxNotificationFallbackIconWorkspace}, {"UserAccountCreated", "", notifications.TemplateUserAccountCreated, codersdk.InboxNotificationFallbackIconAccount}, {"TemplateDeleted", "", notifications.TemplateTemplateDeleted, codersdk.InboxNotificationFallbackIconTemplate}, + {"ChatShared", "", notifications.TemplateChatShared, codersdk.InboxNotificationFallbackIconOther}, {"TestNotification", "", notifications.TemplateTestNotification, codersdk.InboxNotificationFallbackIconOther}, {"TestExistingIcon", "https://cdn.coder.com/icon_notif.png", notifications.TemplateTemplateDeleted, "https://cdn.coder.com/icon_notif.png"}, {"UnknownTemplate", "", uuid.New(), codersdk.InboxNotificationFallbackIconOther}, diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index f586df9fa43..a2da202702b 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -67,4 +67,5 @@ var ( // Chat-related events. var ( TemplateChatAutoArchiveDigest = uuid.MustParse("764031be-4863-4220-867b-6ce1a1b7a5f5") + TemplateChatShared = uuid.MustParse("b789bd75-d7c6-4cab-9757-1147ab184903") ) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 157982a0486..7b7b5daa32b 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1363,6 +1363,21 @@ func TestNotificationTemplates_Golden(t *testing.T) { Data: map[string]any{}, }, }, + { + name: "TemplateChatShared", + id: notifications.TemplateChatShared, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "chat_id": "00000000-0000-0000-0000-000000000001", + "chat_title": "Onboarding kickoff", + "initiator": "alice", + }, + Data: map[string]any{}, + }, + }, { // Default branch: multiple visible chats, retention enabled, // no overflow. Body phrasing is number-neutral so this also diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatShared.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatShared.html.golden new file mode 100644 index 00000000000..d07355ef03d --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatShared.html.golden @@ -0,0 +1,78 @@ +From: system@coder.com +To: bobby@coder.com +Subject: alice shared a chat with you +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +alice shared the chat "Onboarding kickoff" with you. + + +View chat: http://test.com/agents/00000000-0000-0000-0000-000000000001 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Codestin Search App + + +
+
+ 3D"Cod= +
+

+ alice shared a chat with you +

+
+

Hi Bobby,

+

alice shared the chat “Onboarding kickoff= +” with you.

+
+
+ =20 + + View chat + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatShared.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatShared.json.golden new file mode 100644 index 00000000000..2a4ae1aaf89 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatShared.json.golden @@ -0,0 +1,30 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "Chat Shared", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "View chat", + "url": "http://test.com/agents/00000000-0000-0000-0000-000000000000" + } + ], + "labels": { + "chat_id": "00000000-0000-0000-0000-000000000000", + "chat_title": "Onboarding kickoff", + "initiator": "alice" + }, + "data": {}, + "targets": null + }, + "title": "alice shared a chat with you", + "title_markdown": "alice shared a chat with you", + "body": "alice shared the chat \"Onboarding kickoff\" with you.", + "body_markdown": "alice shared the chat \"**Onboarding kickoff**\" with you." +} \ No newline at end of file diff --git a/docs/ai-coder/agents/chat-sharing.md b/docs/ai-coder/agents/chat-sharing.md index 0eeb4f9495e..56475a4b488 100644 --- a/docs/ai-coder/agents/chat-sharing.md +++ b/docs/ai-coder/agents/chat-sharing.md @@ -9,9 +9,9 @@ Chat sharing lets you give other users or groups read-only access to a Coder Age 1. Click the **Search for user or group** field. 1. Search for and select a user or group. 1. Click **Add member** to grant **Read** access. -1. Copy the chat URL from your browser and send it to the recipients. +1. If you shared with a group, copy the chat URL from your browser and send it to the group members. -Coder does not create a separate share link or notify recipients. Recipients need the chat URL for initial access. +Coder does not create a separate share link. Users you share with directly receive a **Chat Shared** notification with a link to open the chat. Members who gain access only through a group are not notified. ## Shared chat access