From 9921c233e9e18b7d04d82dd29c0f25d8b1238ea2 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Fri, 12 Jun 2026 11:26:00 +0000 Subject: [PATCH 01/13] feat(coderd): notify users when chats are shared --- coderd/database/dbauthz/dbauthz.go | 1 + .../000535_chat_shared_notification.down.sql | 1 + .../000535_chat_shared_notification.up.sql | 27 +++++ coderd/database/queries/chats.sql | 1 + coderd/exp_chats_acl.go | 101 ++++++++++++++++++ coderd/exp_chats_acl_test.go | 35 ++++++ coderd/inboxnotifications.go | 1 + coderd/inboxnotifications_internal_test.go | 1 + coderd/notifications/events.go | 1 + coderd/notifications/notifications_test.go | 15 +++ .../smtp/TemplateChatShared.html.golden | 78 ++++++++++++++ .../webhook/TemplateChatShared.json.golden | 30 ++++++ 12 files changed, 292 insertions(+) create mode 100644 coderd/database/migrations/000535_chat_shared_notification.down.sql create mode 100644 coderd/database/migrations/000535_chat_shared_notification.up.sql create mode 100644 coderd/notifications/testdata/rendered-templates/smtp/TemplateChatShared.html.golden create mode 100644 coderd/notifications/testdata/rendered-templates/webhook/TemplateChatShared.json.golden diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 2fbb0cd6534..d9389230eb7 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -397,6 +397,7 @@ var ( rbac.ResourceInboxNotification.Type: {policy.ActionCreate}, rbac.ResourceWebpushSubscription.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceDeploymentConfig.Type: {policy.ActionRead, policy.ActionUpdate}, // To read and upsert VAPID keys + rbac.ResourceGroupMember.Type: {policy.ActionRead}, }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, diff --git a/coderd/database/migrations/000535_chat_shared_notification.down.sql b/coderd/database/migrations/000535_chat_shared_notification.down.sql new file mode 100644 index 00000000000..716f7dc4e2f --- /dev/null +++ b/coderd/database/migrations/000535_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/000535_chat_shared_notification.up.sql b/coderd/database/migrations/000535_chat_shared_notification.up.sql new file mode 100644 index 00000000000..630e20b1233 --- /dev/null +++ b/coderd/database/migrations/000535_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/database/queries/chats.sql b/coderd/database/queries/chats.sql index b7bbe8ecb38..ca65223914c 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -3090,3 +3090,4 @@ LEFT JOIN to_archive t ON t.id = a.id -- created_at ASC flows through to dbpurge's digest truncation; see -- buildDigestData in dbpurge.go for the tradeoff rationale. ORDER BY (a.root_chat_id IS NULL) DESC, a.owner_id ASC, a.created_at ASC, a.id ASC; + diff --git a/coderd/exp_chats_acl.go b/coderd/exp_chats_acl.go index cb9af92f3d8..1b9c72d8a12 100644 --- a/coderd/exp_chats_acl.go +++ b/coderd/exp_chats_acl.go @@ -3,9 +3,12 @@ package coderd import ( "context" "database/sql" + "maps" "net/http" + "slices" "github.com/google/uuid" + "golang.org/x/sync/errgroup" "golang.org/x/xerrors" slog "cdr.dev/slog/v3" @@ -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,9 @@ 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) + oldChat.GroupACL = maps.Clone(current.GroupACL) for id, role := range req.UserRoles { if role == codersdk.ChatRoleDeleted { @@ -199,9 +207,102 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { return } + if err := api.notifyChatShared(ctx, oldChat, aReq.New, apiKey.UserID); err != nil { + api.Logger.Warn(ctx, "failed to enqueue chat shared notification", slog.Error(err), slog.F("chat_id", chat.ID)) + } + rw.WriteHeader(http.StatusNoContent) } +func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, newChat database.Chat, initiatorID uuid.UUID) error { + oldReaders, err := api.effectiveChatReaders(ctx, oldChat) + if err != nil { + return xerrors.Errorf("resolve previous chat readers: %w", err) + } + newReaders, err := api.effectiveChatReaders(ctx, newChat) + if err != nil { + return xerrors.Errorf("resolve current chat readers: %w", err) + } + + recipientIDs := make([]uuid.UUID, 0, len(newReaders)) + for userID := range newReaders { + if _, alreadyReader := oldReaders[userID]; alreadyReader { + continue + } + // The initiator is sharing the chat, so they should not be told the + // chat was shared with them. + if userID == initiatorID { + continue + } + recipientIDs = append(recipientIDs, userID) + } + if len(recipientIDs) == 0 { + return nil + } + + initiator, err := api.Database.GetUserByID(ctx, initiatorID) + if err != nil { + return xerrors.Errorf("get initiator: %w", err) + } + labels := map[string]string{ + "chat_id": newChat.ID.String(), + "chat_title": newChat.Title, + "initiator": initiator.Username, + } + + var eg errgroup.Group + eg.SetLimit(10) + for _, userID := range recipientIDs { + eg.Go(func() error { + //nolint:gocritic // Need notifier actor to enqueue notifications. + _, err := api.NotificationsEnqueuer.Enqueue(dbauthz.AsNotifier(ctx), userID, notifications.TemplateChatShared, labels, initiatorID.String(), newChat.ID) + if err != nil { + return xerrors.Errorf("enqueue chat shared notification: %w", err) + } + return nil + }) + } + return eg.Wait() +} + +func (api *API) effectiveChatReaders(ctx context.Context, chat database.Chat) (map[uuid.UUID]struct{}, error) { + readers := map[uuid.UUID]struct{}{chat.OwnerID: {}} + + for rawUserID, entry := range chat.UserACL { + if !slices.Contains(entry.Permissions, policy.ActionRead) { + continue + } + userID, err := uuid.Parse(rawUserID) + if err != nil { + continue + } + readers[userID] = struct{}{} + } + + for rawGroupID, entry := range chat.GroupACL { + if !slices.Contains(entry.Permissions, policy.ActionRead) { + continue + } + groupID, err := uuid.Parse(rawGroupID) + if err != nil { + continue + } + //nolint:gocritic // Notifier reads group members to deliver notifications to them. + members, err := api.Database.GetGroupMembersByGroupID(dbauthz.AsNotifier(ctx), database.GetGroupMembersByGroupIDParams{ + GroupID: groupID, + IncludeSystem: false, + }) + if err != nil { + return nil, xerrors.Errorf("get members for group %s: %w", groupID, err) + } + for _, member := range members { + readers[member.UserID] = struct{}{} + } + } + + return readers, nil +} + 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..1442903ead4 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,35 @@ func TestChatACLSharingLifecycle(t *testing.T) { ResourceID: chat.ID, UserID: firstUser.UserID, })) + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared)) + require.Len(t, sent, 2) + byUserID := map[uuid.UUID]*notificationstest.FakeNotification{} + for _, notification := range sent { + byUserID[notification.UserID] = notification + } + for _, userID := range []uuid.UUID{sharedUser.ID, groupMember.ID} { + notification := byUserID[userID] + require.NotNil(t, notification) + require.Equal(t, firstUser.UserID.String(), notification.CreatedBy) + require.Equal(t, map[string]string{ + "chat_id": chat.ID.String(), + "chat_title": chat.Title, + "initiator": coderdtest.FirstUserParams.Username, + }, notification.Labels) + require.Equal(t, []uuid.UUID{chat.ID}, notification.Targets) + } + + 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 +189,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 +202,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, 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 From dc8b4d85994aa2fa839ca0f075f989b87e65e0f2 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 1 Jul 2026 14:49:57 +0000 Subject: [PATCH 02/13] refactor(coderd): consolidate chat-shared notifier escalation Derive the notifier context once at the notification boundary instead of inside effectiveChatReaders, document the ResourceGroupMember grant, drop a self-explanatory comment, and revert a stray trailing newline in chats.sql. --- coderd/database/dbauthz/dbauthz.go | 2 +- coderd/database/queries/chats.sql | 1 - coderd/exp_chats_acl.go | 17 +++++++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d9389230eb7..d58b8cdada6 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -397,7 +397,7 @@ var ( rbac.ResourceInboxNotification.Type: {policy.ActionCreate}, rbac.ResourceWebpushSubscription.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceDeploymentConfig.Type: {policy.ActionRead, policy.ActionUpdate}, // To read and upsert VAPID keys - rbac.ResourceGroupMember.Type: {policy.ActionRead}, + rbac.ResourceGroupMember.Type: {policy.ActionRead}, // To resolve group-shared notification recipients }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index ca65223914c..b7bbe8ecb38 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -3090,4 +3090,3 @@ LEFT JOIN to_archive t ON t.id = a.id -- created_at ASC flows through to dbpurge's digest truncation; see -- buildDigestData in dbpurge.go for the tradeoff rationale. ORDER BY (a.root_chat_id IS NULL) DESC, a.owner_id ASC, a.created_at ASC, a.id ASC; - diff --git a/coderd/exp_chats_acl.go b/coderd/exp_chats_acl.go index 1b9c72d8a12..82df4da4f69 100644 --- a/coderd/exp_chats_acl.go +++ b/coderd/exp_chats_acl.go @@ -215,11 +215,16 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { } func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, newChat database.Chat, initiatorID uuid.UUID) error { - oldReaders, err := api.effectiveChatReaders(ctx, oldChat) + // Resolving recipients reads group membership and enqueues notifications, + // neither of which the sharing user is authorized to do. + //nolint:gocritic // Notifier actor is required to read members and enqueue. + notifierCtx := dbauthz.AsNotifier(ctx) + + oldReaders, err := api.effectiveChatReaders(notifierCtx, oldChat) if err != nil { return xerrors.Errorf("resolve previous chat readers: %w", err) } - newReaders, err := api.effectiveChatReaders(ctx, newChat) + newReaders, err := api.effectiveChatReaders(notifierCtx, newChat) if err != nil { return xerrors.Errorf("resolve current chat readers: %w", err) } @@ -229,8 +234,6 @@ func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, new if _, alreadyReader := oldReaders[userID]; alreadyReader { continue } - // The initiator is sharing the chat, so they should not be told the - // chat was shared with them. if userID == initiatorID { continue } @@ -254,8 +257,7 @@ func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, new eg.SetLimit(10) for _, userID := range recipientIDs { eg.Go(func() error { - //nolint:gocritic // Need notifier actor to enqueue notifications. - _, err := api.NotificationsEnqueuer.Enqueue(dbauthz.AsNotifier(ctx), userID, notifications.TemplateChatShared, labels, initiatorID.String(), newChat.ID) + _, err := api.NotificationsEnqueuer.Enqueue(notifierCtx, userID, notifications.TemplateChatShared, labels, initiatorID.String(), newChat.ID) if err != nil { return xerrors.Errorf("enqueue chat shared notification: %w", err) } @@ -287,8 +289,7 @@ func (api *API) effectiveChatReaders(ctx context.Context, chat database.Chat) (m if err != nil { continue } - //nolint:gocritic // Notifier reads group members to deliver notifications to them. - members, err := api.Database.GetGroupMembersByGroupID(dbauthz.AsNotifier(ctx), database.GetGroupMembersByGroupIDParams{ + members, err := api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{ GroupID: groupID, IncludeSystem: false, }) From d76d39efabbcf00a3754b18ff7c18aede15494ad Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 1 Jul 2026 17:06:03 +0000 Subject: [PATCH 03/13] fix(coderd): background chat-shared notification fan-out Run the chat-shared notification fan-out on the server context instead of the request context so a large group does not block the PATCH response and a client disconnect does not drop notifications. Log the recipient count on failure and log ACL entries with invalid UUIDs. Add a regression test for a non-owner group-member initiator and direct/group reader deduplication. --- coderd/exp_chats_acl.go | 39 +++++++++++++---------- coderd/exp_chats_acl_test.go | 60 ++++++++++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/coderd/exp_chats_acl.go b/coderd/exp_chats_acl.go index 82df4da4f69..18e2b60c70c 100644 --- a/coderd/exp_chats_acl.go +++ b/coderd/exp_chats_acl.go @@ -207,26 +207,35 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { return } - if err := api.notifyChatShared(ctx, oldChat, aReq.New, apiKey.UserID); err != nil { - api.Logger.Warn(ctx, "failed to enqueue chat shared notification", slog.Error(err), slog.F("chat_id", chat.ID)) + // Load under the request actor; the notifier actor cannot read users. + 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 { + // Fan out on the server context so a large group does not block the + // response and a client disconnect does not drop notifications. + newChat := aReq.New + go func() { + if count, err := api.notifyChatShared(api.ctx, oldChat, newChat, initiator); err != nil { + api.Logger.Warn(api.ctx, "failed to enqueue chat shared notification", slog.Error(err), slog.F("chat_id", newChat.ID), slog.F("recipient_count", count)) + } + }() } rw.WriteHeader(http.StatusNoContent) } -func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, newChat database.Chat, initiatorID uuid.UUID) error { - // Resolving recipients reads group membership and enqueues notifications, - // neither of which the sharing user is authorized to do. - //nolint:gocritic // Notifier actor is required to read members and enqueue. +func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, newChat database.Chat, initiator database.User) (int, error) { + //nolint:gocritic // Notifier actor is required to read group members and enqueue. notifierCtx := dbauthz.AsNotifier(ctx) oldReaders, err := api.effectiveChatReaders(notifierCtx, oldChat) if err != nil { - return xerrors.Errorf("resolve previous chat readers: %w", err) + return 0, xerrors.Errorf("resolve previous chat readers: %w", err) } newReaders, err := api.effectiveChatReaders(notifierCtx, newChat) if err != nil { - return xerrors.Errorf("resolve current chat readers: %w", err) + return 0, xerrors.Errorf("resolve current chat readers: %w", err) } recipientIDs := make([]uuid.UUID, 0, len(newReaders)) @@ -234,19 +243,15 @@ func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, new if _, alreadyReader := oldReaders[userID]; alreadyReader { continue } - if userID == initiatorID { + if userID == initiator.ID { continue } recipientIDs = append(recipientIDs, userID) } if len(recipientIDs) == 0 { - return nil + return 0, nil } - initiator, err := api.Database.GetUserByID(ctx, initiatorID) - if err != nil { - return xerrors.Errorf("get initiator: %w", err) - } labels := map[string]string{ "chat_id": newChat.ID.String(), "chat_title": newChat.Title, @@ -257,14 +262,14 @@ func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, new eg.SetLimit(10) for _, userID := range recipientIDs { eg.Go(func() error { - _, err := api.NotificationsEnqueuer.Enqueue(notifierCtx, userID, notifications.TemplateChatShared, labels, initiatorID.String(), newChat.ID) + _, err := api.NotificationsEnqueuer.Enqueue(notifierCtx, userID, notifications.TemplateChatShared, labels, initiator.ID.String(), newChat.ID) if err != nil { return xerrors.Errorf("enqueue chat shared notification: %w", err) } return nil }) } - return eg.Wait() + return len(recipientIDs), eg.Wait() } func (api *API) effectiveChatReaders(ctx context.Context, chat database.Chat) (map[uuid.UUID]struct{}, error) { @@ -276,6 +281,7 @@ func (api *API) effectiveChatReaders(ctx context.Context, chat database.Chat) (m } userID, err := uuid.Parse(rawUserID) if err != nil { + api.Logger.Warn(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[userID] = struct{}{} @@ -287,6 +293,7 @@ func (api *API) effectiveChatReaders(ctx context.Context, chat database.Chat) (m } groupID, err := uuid.Parse(rawGroupID) if err != nil { + api.Logger.Warn(ctx, "skip chat ACL entry with invalid group UUID", slog.F("chat_id", chat.ID), slog.F("group_id", rawGroupID), slog.Error(err)) continue } members, err := api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{ diff --git a/coderd/exp_chats_acl_test.go b/coderd/exp_chats_acl_test.go index 1442903ead4..ae71b2043d9 100644 --- a/coderd/exp_chats_acl_test.go +++ b/coderd/exp_chats_acl_test.go @@ -72,8 +72,11 @@ func TestChatACLSharingLifecycle(t *testing.T) { ResourceID: chat.ID, UserID: firstUser.UserID, })) - sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared)) - require.Len(t, sent, 2) + var sent []*notificationstest.FakeNotification + testutil.Eventually(ctx, t, func(context.Context) bool { + sent = notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared)) + return len(sent) == 2 + }, testutil.IntervalFast) byUserID := map[uuid.UUID]*notificationstest.FakeNotification{} for _, notification := range sent { byUserID[notification.UserID] = notification @@ -213,6 +216,59 @@ func TestChatACLSharingLifecycle(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) } +// TestChatACLSharingExcludesGroupMemberInitiator verifies that a non-owner +// initiator who is a member of a newly shared group is not self-notified, and +// that a reader granted through both a direct ACL entry and a group is +// notified only once. +func TestChatACLSharingExcludesGroupMemberInitiator(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) + + // 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, and also with groupMember directly so that reader + // is granted access through both paths (exercises deduplication). + err := adminExp.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{ + UserRoles: map[string]codersdk.ChatRole{ + groupMember.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) + // groupMember is notified exactly once despite the direct and group grants. + // The initiator (admin) and owner (firstUser) are never notified. + require.Equal(t, groupMember.ID, sent[0].UserID) + for _, notification := range sent { + require.NotEqual(t, admin.ID, notification.UserID) + require.NotEqual(t, firstUser.UserID, notification.UserID) + } +} + func TestChatACLSubChatInheritance(t *testing.T) { t.Parallel() From e62d6a0f5c3abd6fbf982562587f34ed54362df8 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 2 Jul 2026 13:35:15 +0000 Subject: [PATCH 04/13] fix(coderd): only notify direct user-ACL grants on chat share --- coderd/database/dbauthz/dbauthz.go | 1 - coderd/exp_chats_acl.go | 48 ++++++---------------------- coderd/exp_chats_acl_test.go | 50 +++++++++++++++--------------- 3 files changed, 35 insertions(+), 64 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d58b8cdada6..2fbb0cd6534 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -397,7 +397,6 @@ var ( rbac.ResourceInboxNotification.Type: {policy.ActionCreate}, rbac.ResourceWebpushSubscription.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceDeploymentConfig.Type: {policy.ActionRead, policy.ActionUpdate}, // To read and upsert VAPID keys - rbac.ResourceGroupMember.Type: {policy.ActionRead}, // To resolve group-shared notification recipients }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, diff --git a/coderd/exp_chats_acl.go b/coderd/exp_chats_acl.go index 18e2b60c70c..7656f4e1c9d 100644 --- a/coderd/exp_chats_acl.go +++ b/coderd/exp_chats_acl.go @@ -212,8 +212,8 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { if err != nil { api.Logger.Warn(ctx, "failed to load chat share initiator", slog.Error(err), slog.F("chat_id", chat.ID)) } else { - // Fan out on the server context so a large group does not block the - // response and a client disconnect does not drop notifications. + // Fan out on the server context, not the request, so a client + // disconnect after the ACL commit does not drop notifications. newChat := aReq.New go func() { if count, err := api.notifyChatShared(api.ctx, oldChat, newChat, initiator); err != nil { @@ -226,17 +226,8 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { } func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, newChat database.Chat, initiator database.User) (int, error) { - //nolint:gocritic // Notifier actor is required to read group members and enqueue. - notifierCtx := dbauthz.AsNotifier(ctx) - - oldReaders, err := api.effectiveChatReaders(notifierCtx, oldChat) - if err != nil { - return 0, xerrors.Errorf("resolve previous chat readers: %w", err) - } - newReaders, err := api.effectiveChatReaders(notifierCtx, newChat) - if err != nil { - return 0, xerrors.Errorf("resolve current chat readers: %w", err) - } + oldReaders := api.directChatReaders(ctx, oldChat) + newReaders := api.directChatReaders(ctx, newChat) recipientIDs := make([]uuid.UUID, 0, len(newReaders)) for userID := range newReaders { @@ -258,6 +249,8 @@ func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, new "initiator": initiator.Username, } + //nolint:gocritic // Notifier actor is required to enqueue notifications. + notifierCtx := dbauthz.AsNotifier(ctx) var eg errgroup.Group eg.SetLimit(10) for _, userID := range recipientIDs { @@ -272,9 +265,10 @@ func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, new return len(recipientIDs), eg.Wait() } -func (api *API) effectiveChatReaders(ctx context.Context, chat database.Chat) (map[uuid.UUID]struct{}, error) { +// directChatReaders returns users granted read via the user ACL. Group-ACL +// grants are excluded so sharing with a group does not notify its members. +func (api *API) directChatReaders(ctx context.Context, chat database.Chat) map[uuid.UUID]struct{} { readers := map[uuid.UUID]struct{}{chat.OwnerID: {}} - for rawUserID, entry := range chat.UserACL { if !slices.Contains(entry.Permissions, policy.ActionRead) { continue @@ -286,29 +280,7 @@ func (api *API) effectiveChatReaders(ctx context.Context, chat database.Chat) (m } readers[userID] = struct{}{} } - - for rawGroupID, entry := range chat.GroupACL { - if !slices.Contains(entry.Permissions, policy.ActionRead) { - continue - } - groupID, err := uuid.Parse(rawGroupID) - if err != nil { - api.Logger.Warn(ctx, "skip chat ACL entry with invalid group UUID", slog.F("chat_id", chat.ID), slog.F("group_id", rawGroupID), slog.Error(err)) - continue - } - members, err := api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{ - GroupID: groupID, - IncludeSystem: false, - }) - if err != nil { - return nil, xerrors.Errorf("get members for group %s: %w", groupID, err) - } - for _, member := range members { - readers[member.UserID] = struct{}{} - } - } - - return readers, nil + return readers } func (api *API) chatACLUsers(ctx context.Context, rw http.ResponseWriter, chat database.Chat, entries database.ChatACL) ([]codersdk.ChatUser, bool) { diff --git a/coderd/exp_chats_acl_test.go b/coderd/exp_chats_acl_test.go index ae71b2043d9..53a237f01fb 100644 --- a/coderd/exp_chats_acl_test.go +++ b/coderd/exp_chats_acl_test.go @@ -72,25 +72,23 @@ 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) == 2 + return len(sent) == 1 }, testutil.IntervalFast) - byUserID := map[uuid.UUID]*notificationstest.FakeNotification{} + 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 { - byUserID[notification.UserID] = notification - } - for _, userID := range []uuid.UUID{sharedUser.ID, groupMember.ID} { - notification := byUserID[userID] - require.NotNil(t, notification) - require.Equal(t, firstUser.UserID.String(), notification.CreatedBy) - require.Equal(t, map[string]string{ - "chat_id": chat.ID.String(), - "chat_title": chat.Title, - "initiator": coderdtest.FirstUserParams.Username, - }, notification.Labels) - require.Equal(t, []uuid.UUID{chat.ID}, notification.Targets) + require.NotEqual(t, groupMember.ID, notification.UserID) } notifyEnq.Clear() @@ -216,11 +214,11 @@ func TestChatACLSharingLifecycle(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) } -// TestChatACLSharingExcludesGroupMemberInitiator verifies that a non-owner -// initiator who is a member of a newly shared group is not self-notified, and -// that a reader granted through both a direct ACL entry and a group is -// notified only once. -func TestChatACLSharingExcludesGroupMemberInitiator(t *testing.T) { +// TestChatACLSharingNotifiesDirectReadersOnly verifies that only users granted +// read through the user ACL are notified. Members who gain access solely +// through a group grant are not notified, and the initiator is never +// self-notified even when the direct grant would otherwise include them. +func TestChatACLSharingNotifiesDirectReadersOnly(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -235,6 +233,7 @@ func TestChatACLSharingExcludesGroupMemberInitiator(t *testing.T) { 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}) @@ -243,11 +242,11 @@ func TestChatACLSharingExcludesGroupMemberInitiator(t *testing.T) { chat := createChatForSharing(ctx, t, client, firstUser.OrganizationID, "admin shared chat") - // Share with the group, and also with groupMember directly so that reader - // is granted access through both paths (exercises deduplication). + // 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{ - groupMember.ID.String(): codersdk.ChatRoleRead, + directUser.ID.String(): codersdk.ChatRoleRead, }, GroupRoles: map[string]codersdk.ChatRole{ group.ID.String(): codersdk.ChatRoleRead, @@ -260,10 +259,11 @@ func TestChatACLSharingExcludesGroupMemberInitiator(t *testing.T) { sent = notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared)) return len(sent) == 1 }, testutil.IntervalFast) - // groupMember is notified exactly once despite the direct and group grants. - // The initiator (admin) and owner (firstUser) are never notified. - require.Equal(t, groupMember.ID, sent[0].UserID) + // 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) } From c9a18ef0179c77e7d19cd34b2dbe7164a4814b60 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 2 Jul 2026 13:44:26 +0000 Subject: [PATCH 05/13] docs(coderd): drop redundant directChatReaders comment --- coderd/exp_chats_acl.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/coderd/exp_chats_acl.go b/coderd/exp_chats_acl.go index 7656f4e1c9d..9b86004ee84 100644 --- a/coderd/exp_chats_acl.go +++ b/coderd/exp_chats_acl.go @@ -265,8 +265,6 @@ func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, new return len(recipientIDs), eg.Wait() } -// directChatReaders returns users granted read via the user ACL. Group-ACL -// grants are excluded so sharing with a group does not notify its members. func (api *API) directChatReaders(ctx context.Context, chat database.Chat) map[uuid.UUID]struct{} { readers := map[uuid.UUID]struct{}{chat.OwnerID: {}} for rawUserID, entry := range chat.UserACL { From 738121321675dac363b48c0adc27432ecdc6641c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 6 Jul 2026 09:03:24 +0000 Subject: [PATCH 06/13] fix(coderd/database/migrations): renumber chat shared migration to 000538 --- ...fication.down.sql => 000538_chat_shared_notification.down.sql} | 0 ...notification.up.sql => 000538_chat_shared_notification.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000535_chat_shared_notification.down.sql => 000538_chat_shared_notification.down.sql} (100%) rename coderd/database/migrations/{000535_chat_shared_notification.up.sql => 000538_chat_shared_notification.up.sql} (100%) diff --git a/coderd/database/migrations/000535_chat_shared_notification.down.sql b/coderd/database/migrations/000538_chat_shared_notification.down.sql similarity index 100% rename from coderd/database/migrations/000535_chat_shared_notification.down.sql rename to coderd/database/migrations/000538_chat_shared_notification.down.sql diff --git a/coderd/database/migrations/000535_chat_shared_notification.up.sql b/coderd/database/migrations/000538_chat_shared_notification.up.sql similarity index 100% rename from coderd/database/migrations/000535_chat_shared_notification.up.sql rename to coderd/database/migrations/000538_chat_shared_notification.up.sql From 93b562cbd67d4fd2b7741f6563d7bee2976a99ca Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 6 Jul 2026 10:41:50 +0000 Subject: [PATCH 07/13] docs(coderd): drop redundant chat share comments --- coderd/exp_chats_acl.go | 3 --- coderd/exp_chats_acl_test.go | 4 ---- 2 files changed, 7 deletions(-) diff --git a/coderd/exp_chats_acl.go b/coderd/exp_chats_acl.go index 9b86004ee84..95dbbaa8a7e 100644 --- a/coderd/exp_chats_acl.go +++ b/coderd/exp_chats_acl.go @@ -207,13 +207,10 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { return } - // Load under the request actor; the notifier actor cannot read users. 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 { - // Fan out on the server context, not the request, so a client - // disconnect after the ACL commit does not drop notifications. newChat := aReq.New go func() { if count, err := api.notifyChatShared(api.ctx, oldChat, newChat, initiator); err != nil { diff --git a/coderd/exp_chats_acl_test.go b/coderd/exp_chats_acl_test.go index 53a237f01fb..ab109e473eb 100644 --- a/coderd/exp_chats_acl_test.go +++ b/coderd/exp_chats_acl_test.go @@ -214,10 +214,6 @@ func TestChatACLSharingLifecycle(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) } -// TestChatACLSharingNotifiesDirectReadersOnly verifies that only users granted -// read through the user ACL are notified. Members who gain access solely -// through a group grant are not notified, and the initiator is never -// self-notified even when the direct grant would otherwise include them. func TestChatACLSharingNotifiesDirectReadersOnly(t *testing.T) { t.Parallel() From e453b6c6676e32c15d288a2fc726717dd312f15d Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 6 Jul 2026 10:43:18 +0000 Subject: [PATCH 08/13] docs(coderd): note server context use in chat share fan-out --- coderd/exp_chats_acl.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/exp_chats_acl.go b/coderd/exp_chats_acl.go index 95dbbaa8a7e..4ccfb951ad2 100644 --- a/coderd/exp_chats_acl.go +++ b/coderd/exp_chats_acl.go @@ -211,6 +211,7 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { if err != nil { api.Logger.Warn(ctx, "failed to load chat share initiator", slog.Error(err), slog.F("chat_id", chat.ID)) } else { + // api.ctx, not the request ctx, so disconnects don't drop notifications. newChat := aReq.New go func() { if count, err := api.notifyChatShared(api.ctx, oldChat, newChat, initiator); err != nil { From 9f8c36490177e4fb2601bcaac0b3b0a822f02bfc Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 6 Jul 2026 11:09:44 +0000 Subject: [PATCH 09/13] refactor(coderd): simplify chat shared notification fan-out --- coderd/exp_chats_acl.go | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/coderd/exp_chats_acl.go b/coderd/exp_chats_acl.go index 4ccfb951ad2..908860ce03f 100644 --- a/coderd/exp_chats_acl.go +++ b/coderd/exp_chats_acl.go @@ -3,12 +3,12 @@ package coderd import ( "context" "database/sql" + "errors" "maps" "net/http" "slices" "github.com/google/uuid" - "golang.org/x/sync/errgroup" "golang.org/x/xerrors" slog "cdr.dev/slog/v3" @@ -163,7 +163,6 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { } oldChat = current oldChat.UserACL = maps.Clone(current.UserACL) - oldChat.GroupACL = maps.Clone(current.GroupACL) for id, role := range req.UserRoles { if role == codersdk.ChatRoleDeleted { @@ -249,18 +248,13 @@ func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, new //nolint:gocritic // Notifier actor is required to enqueue notifications. notifierCtx := dbauthz.AsNotifier(ctx) - var eg errgroup.Group - eg.SetLimit(10) + var errs []error for _, userID := range recipientIDs { - eg.Go(func() error { - _, err := api.NotificationsEnqueuer.Enqueue(notifierCtx, userID, notifications.TemplateChatShared, labels, initiator.ID.String(), newChat.ID) - if err != nil { - return xerrors.Errorf("enqueue chat shared notification: %w", err) - } - return nil - }) + 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), eg.Wait() + return len(recipientIDs), errors.Join(errs...) } func (api *API) directChatReaders(ctx context.Context, chat database.Chat) map[uuid.UUID]struct{} { From 3dff70d979a703a33c7171eaa566a100e0fda2c0 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 6 Jul 2026 11:20:17 +0000 Subject: [PATCH 10/13] docs(docs/ai-coder/agents): note chat shared notification for direct shares --- docs/ai-coder/agents/chat-sharing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ai-coder/agents/chat-sharing.md b/docs/ai-coder/agents/chat-sharing.md index 0eeb4f9495e..0706be869bd 100644 --- a/docs/ai-coder/agents/chat-sharing.md +++ b/docs/ai-coder/agents/chat-sharing.md @@ -11,7 +11,7 @@ Chat sharing lets you give other users or groups read-only access to a Coder Age 1. Click **Add member** to grant **Read** access. 1. Copy the chat URL from your browser and send it to the recipients. -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, so send them the chat URL for initial access. ## Shared chat access From 298afd7f0e518b569939be472d8b90d16f2457f9 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 6 Jul 2026 11:43:31 +0000 Subject: [PATCH 11/13] docs(coderd): sharpen chat share notification comment, log, and docs --- coderd/exp_chats_acl.go | 4 ++-- docs/ai-coder/agents/chat-sharing.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/coderd/exp_chats_acl.go b/coderd/exp_chats_acl.go index 908860ce03f..013f808ed2d 100644 --- a/coderd/exp_chats_acl.go +++ b/coderd/exp_chats_acl.go @@ -210,11 +210,11 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { if err != nil { api.Logger.Warn(ctx, "failed to load chat share initiator", slog.Error(err), slog.F("chat_id", chat.ID)) } else { - // api.ctx, not the request ctx, so disconnects don't drop notifications. + // api.ctx, not the request ctx, so a disconnect no longer cancels the fan-out once it starts. newChat := aReq.New go func() { if count, err := api.notifyChatShared(api.ctx, oldChat, newChat, initiator); err != nil { - api.Logger.Warn(api.ctx, "failed to enqueue chat shared notification", slog.Error(err), slog.F("chat_id", newChat.ID), slog.F("recipient_count", count)) + 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)) } }() } diff --git a/docs/ai-coder/agents/chat-sharing.md b/docs/ai-coder/agents/chat-sharing.md index 0706be869bd..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. 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, so send them 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 From 1952856178afb2915d5db3111a61336faa720763 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 6 Jul 2026 11:49:32 +0000 Subject: [PATCH 12/13] refactor(coderd): use slice helpers for chat share reader diff --- coderd/exp_chats_acl.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/coderd/exp_chats_acl.go b/coderd/exp_chats_acl.go index 013f808ed2d..5ee68c592ea 100644 --- a/coderd/exp_chats_acl.go +++ b/coderd/exp_chats_acl.go @@ -226,11 +226,9 @@ func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, new oldReaders := api.directChatReaders(ctx, oldChat) newReaders := api.directChatReaders(ctx, newChat) - recipientIDs := make([]uuid.UUID, 0, len(newReaders)) - for userID := range newReaders { - if _, alreadyReader := oldReaders[userID]; alreadyReader { - continue - } + added, _ := slice.SymmetricDifference(oldReaders, newReaders) + recipientIDs := make([]uuid.UUID, 0, len(added)) + for _, userID := range added { if userID == initiator.ID { continue } @@ -257,8 +255,8 @@ func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, new return len(recipientIDs), errors.Join(errs...) } -func (api *API) directChatReaders(ctx context.Context, chat database.Chat) map[uuid.UUID]struct{} { - readers := map[uuid.UUID]struct{}{chat.OwnerID: {}} +func (api *API) directChatReaders(ctx context.Context, chat database.Chat) []uuid.UUID { + readers := []uuid.UUID{chat.OwnerID} for rawUserID, entry := range chat.UserACL { if !slices.Contains(entry.Permissions, policy.ActionRead) { continue @@ -268,9 +266,9 @@ func (api *API) directChatReaders(ctx context.Context, chat database.Chat) map[u api.Logger.Warn(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[userID] = struct{}{} + readers = append(readers, userID) } - return readers + return slice.Unique(readers) } func (api *API) chatACLUsers(ctx context.Context, rw http.ResponseWriter, chat database.Chat, entries database.ChatACL) ([]codersdk.ChatUser, bool) { From 891fb8c3ab35f5b115209fe3af5f38f6a3637d47 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 6 Jul 2026 12:00:45 +0000 Subject: [PATCH 13/13] refactor(coderd): drop ctx threading in chat share fan-out --- coderd/exp_chats_acl.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/coderd/exp_chats_acl.go b/coderd/exp_chats_acl.go index 5ee68c592ea..889cd933745 100644 --- a/coderd/exp_chats_acl.go +++ b/coderd/exp_chats_acl.go @@ -210,10 +210,9 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { if err != nil { api.Logger.Warn(ctx, "failed to load chat share initiator", slog.Error(err), slog.F("chat_id", chat.ID)) } else { - // api.ctx, not the request ctx, so a disconnect no longer cancels the fan-out once it starts. newChat := aReq.New go func() { - if count, err := api.notifyChatShared(api.ctx, oldChat, newChat, initiator); err != nil { + 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)) } }() @@ -222,9 +221,9 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) } -func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, newChat database.Chat, initiator database.User) (int, error) { - oldReaders := api.directChatReaders(ctx, oldChat) - newReaders := api.directChatReaders(ctx, newChat) +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)) @@ -245,7 +244,7 @@ func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, new } //nolint:gocritic // Notifier actor is required to enqueue notifications. - notifierCtx := dbauthz.AsNotifier(ctx) + 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 { @@ -255,7 +254,7 @@ func (api *API) notifyChatShared(ctx context.Context, oldChat database.Chat, new return len(recipientIDs), errors.Join(errs...) } -func (api *API) directChatReaders(ctx context.Context, chat database.Chat) []uuid.UUID { +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) { @@ -263,7 +262,7 @@ func (api *API) directChatReaders(ctx context.Context, chat database.Chat) []uui } userID, err := uuid.Parse(rawUserID) if err != nil { - api.Logger.Warn(ctx, "skip chat ACL entry with invalid user UUID", slog.F("chat_id", chat.ID), slog.F("user_id", rawUserID), slog.Error(err)) + 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)