feat(coderd): notify users when chats are shared - #26329
Conversation
|
/coder-agents-review |
|
Chat: Review in progress | View chat deep-review v0.7.1 | Round 1 | Last posted: Round 1, 10 findings (1 P1, 2 P2, 5 P3, 2 Nit), REQUEST_CHANGES. Review Finding inventoryFindings
Round logRound 1Panel. 1 P1, 2 P2, 4 P3, 2 Nit. Reviewed against 4a07f61..fe660c0. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
Clean feature, proportional change: 97 lines of production code, 145 lines of test, correct notification plumbing (migration, events.go constant, inbox fallback, golden files). Test coverage is genuine and well-structured.
Severity count: 1 P1, 2 P2, 4 P3, 2 Nit.
The P1 is a silent correctness bug: GetGroupMembersByGroupID runs through dbauthz with the caller's auth context, but fetchWithPostFilter silently drops group members the caller cannot read. Every sibling call in the same file escalates to dbauthz.AsSystemRestricted(ctx). The test passes because the first user is an admin. A non-admin with agents-access sharing with a group would see the ACL update succeed but zero notifications reach group members.
The two P2s are: (1) notifications fire for every ChatRoleRead entry in the request without diffing against the old ACL state (aReq.Old is available at the call site but never passed to notifyChatShared), causing spurious re-notifications on idempotent re-shares; (2) unbounded errgroup.Group fans out one goroutine per recipient with no concurrency limit while the HTTP handler blocks at eg.Wait(), and the same file already uses eg.SetLimit(5) for an identical pattern 40 lines away.
Interaction note: fixing CRF-4 (diff old/new ACL) would also resolve CRF-1 (initiator self-notification), since the initiator is always in the old ACL.
"A non-admin user with agents-access can share chats but the post-filter silently drops every group member except themselves. The ACL update succeeds, recipients gain chat access, but zero notifications reach them." Hisoka
🤖 This review was automatically generated with Coder Agents.
| if err != nil { | ||
| continue | ||
| } | ||
| members, err := store.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{ |
There was a problem hiding this comment.
P1 [CRF-3] Group member lookup uses the caller's RBAC context. GetGroupMembersByGroupID is wrapped by dbauthz's fetchWithPostFilter(q.auth, policy.ActionRead, ...), which silently drops group members the calling actor cannot read. A non-admin user with agents-access sharing a chat with a group will see the ACL update succeed, but fetchWithPostFilter filters out every member except themselves (the member role's floor grants only read-self on ResourceGroupMember). Zero notifications reach group members.
Every sibling call in this file escalates:
chatACLUsers(line 217):dbauthz.AsSystemRestricted(ctx)chatACLGroups(line 249):dbauthz.AsSystemRestricted(ctx)GetGroupMembersCountByGroupID(line 259):dbauthz.AsSystemRestricted(ctx)
The Enqueue call on line 262 already elevates to dbauthz.AsNotifier(ctx), but both GetGroupMembersByGroupID and GetUserByID (line 247) run with the raw request context.
Fix: use dbauthz.AsSystemRestricted(ctx) (or dbauthz.AsNotifier(ctx)) for both database reads inside this function, matching the established pattern. Add a //nolint:gocritic comment explaining the elevation. (Hisoka P1, Chopper P1, Mafuuu P2, Kite P2)
🤖
There was a problem hiding this comment.
🤖 This comment was written by Coder Agent on behalf of Danielle Maywood 🤖
Fixed, though not via AsSystemRestricted. The per-recipient group expansion in the handler is gone. Recipients are now computed by a single domain-specific query, GetChatShareNotificationRecipientIDs, that expands groups through group_members_expanded (filtering user_is_system = FALSE) and returns only newly-granted readers (new_readers EXCEPT old_readers). Because expansion happens inside one query rather than through fetchWithPostFilter, no members are silently dropped by the caller's RBAC floor.
We deliberately avoided AsSystemRestricted here: escalating a reusable group-membership read is a broader capability than this side effect needs. Instead the query is scoped to return only recipient IDs, and its dbauthz wrapper only requires a valid actor in context.
| rw.WriteHeader(http.StatusNoContent) | ||
| } | ||
|
|
||
| func (api *API) notifyChatShared(ctx context.Context, store database.Store, chat database.Chat, initiatorID uuid.UUID, req codersdk.UpdateChatACL) error { |
There was a problem hiding this comment.
P2 [CRF-4] notifyChatShared determines recipients from req.UserRoles and req.GroupRoles (the raw HTTP request body), not from a diff of old vs. new ACL state. If a client re-submits an existing ChatRoleRead entry, that user receives a spurious "shared a chat with you" notification even though their access did not change.
Both old and new ACL state are available at the call site: aReq.Old (line 107, chat.UserACL / chat.GroupACL) and aReq.New (line 192). Neither is compared to determine which users are newly shared.
The daily dedupe hash (notification_messages_dedupe_hash_idx) prevents same-day duplicates, and the frontend sends per-entity deltas, so practical risk through the current UI is low. But the API surface is public, and any client that re-applies ACLs (retries, reconciliation, bulk updates) will trigger spurious notifications cross-day.
Fix: pass both old and new database.Chat to notifyChatShared. Compute newly-added users as keys(new.UserACL) - keys(old.UserACL) for direct shares, and keys(new.GroupACL) - keys(old.GroupACL) for groups. This also resolves CRF-1 (initiator self-notification), since the initiator is always in the old ACL. (Meruem P2, Knov P2, Mafuuu P3, Pariston P3)
🤖
There was a problem hiding this comment.
🤖 This comment was written by Coder Agent on behalf of Danielle Maywood 🤖
Fixed. notifyChatShared now takes both the old and new database.Chat. oldChat is captured inside the transaction before mutation (maps.Clone of UserACL/GroupACL). Recipients are computed as new readers EXCEPT old readers, expanding both direct users and group members, so re-submitting an existing ChatRoleRead entry notifies nobody. A lifecycle test re-applies an unchanged ACL and asserts zero notifications. This also resolves CRF-1.
| var eg errgroup.Group | ||
| for _, userID := range userIDs { | ||
| userID := userID |
There was a problem hiding this comment.
P2 [CRF-5] var eg errgroup.Group spawns one goroutine per recipient with no concurrency limit. The HTTP handler blocks at eg.Wait() (line 269) before writing the 204 response. Each goroutine calls Enqueue, which performs DB writes. Sharing with the "Everyone" group (where group_id == organization_id) returns every org member. For a 500-user org, that is 500+ goroutines competing for DB connections from a single HTTP request.
The same file already has the bounded pattern:
// exp_chats.go:7649-7650
eg, egCtx := errgroup.WithContext(ctx)
eg.SetLimit(5)Fix: add eg.SetLimit(5) (or similar) to match the existing convention. Alternatively, consider enqueuing asynchronously after writing the response to avoid blocking the HTTP handler entirely. (Pariston P2, Hisoka P3, Meruem P3)
🤖
There was a problem hiding this comment.
🤖 This comment was written by Coder Agent on behalf of Danielle Maywood 🤖
Fixed. eg.SetLimit(10) now bounds the enqueue fan-out. The blast radius is also much smaller after the CRF-4 redesign: the diff query returns only newly-granted readers rather than every group member, so re-shares and no-op ACL updates enqueue nothing. I used 10 rather than the cited 5; happy to align to 5 if you'd prefer strict consistency with exp_chats.go.
| userIDs = append(userIDs, member.UserID) | ||
| } | ||
| } | ||
| userIDs = slice.Unique(userIDs) |
There was a problem hiding this comment.
P3 [CRF-1] The initiator can receive their own "shared a chat with you" notification when they are a member of a shared group. notifyChatShared collects all group members into userIDs (line 238-239), deduplicates (line 242), then notifies everyone. It never removes initiatorID from the list.
Note: if CRF-4 is fixed (diff old/new ACL state), this is resolved automatically since the initiator is always in the old ACL. If CRF-4 is not fixed, add a filter: userIDs = slices.DeleteFunc(userIDs, func(id uuid.UUID) bool { return id == initiatorID }) after slice.Unique. (Netero)
🤖
There was a problem hiding this comment.
🤖 This comment was written by Coder Agent on behalf of Danielle Maywood 🤖
Resolved by the old/new reader diff, as you predicted. Anyone sharing a chat already has read access to it, so the initiator is part of old_readers and is removed by the new_readers EXCEPT old_readers computation, even when they belong to a newly-shared group. The owner is also seeded explicitly into both reader sets.
| ResourceID: chat.ID, | ||
| UserID: firstUser.UserID, | ||
| })) | ||
| sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared)) |
There was a problem hiding this comment.
P3 [CRF-6] No negative assertion that unsharing suppresses notifications. The test verifies 2 notifications after the share (line 76), then proceeds to unshare the user (line 174) and the group (line 186). Both unshare calls hit patchChatACL, which unconditionally calls notifyChatShared. The role != ChatRoleRead filter at line 214 silently drops ChatRoleDeleted entries, preventing spurious notifications. But no assertion checks that the notification count remains 2 after unsharing.
If someone weakens or inverts the filter, users would receive "X shared a chat with you" at the moment they lose access. No test would catch it.
Fix: after the second unshare, add require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateChatShared)), 2). (Bisky)
🤖
There was a problem hiding this comment.
🤖 This comment was written by Coder Agent on behalf of Danielle Maywood 🤖
Added. The lifecycle test now clears the enqueuer and asserts require.Empty after unsharing the user and after unsharing the group, so weakening or inverting the ChatRoleDeleted filter would fail the test. It also asserts Empty after re-applying an unchanged ACL. I used Clear() + Empty rather than asserting the count stays at 2, which pins those operations to exactly zero new sends.
| IncludeSystem: false, | ||
| }) | ||
| if err != nil { | ||
| return xerrors.Errorf("get group members by group ID: %w", err) |
There was a problem hiding this comment.
P3 [CRF-7] Error message wraps as "get group members by group ID: ..." without the group ID. If a PATCH shares with multiple groups and one lookup fails, the operator knows a group member lookup failed for a given chat but not which group.
Fix: xerrors.Errorf("get group members for group %s: %w", groupID, err) (Chopper)
🤖
There was a problem hiding this comment.
🤖 This comment was written by Coder Agent on behalf of Danielle Maywood 🤖
Obsolete after the redesign. The per-group GetGroupMembersByGroupID loop no longer exists; group expansion happens inside the single GetChatShareNotificationRecipientIDs query. There is no longer a per-group error to annotate, the query either succeeds for all groups or returns one wrapped error (get chat share notification recipients: %w).
| GroupID: groupID, | ||
| IncludeSystem: false, | ||
| }) | ||
| if err != nil { |
There was a problem hiding this comment.
P3 [CRF-8] If GetGroupMembersByGroupID errors for one group, the function returns immediately. The user ID collection loop exits, no deduplication runs, no initiator lookup runs, and no notifications are enqueued for anyone. Users directly shared via req.UserRoles (already collected before the group loop) also lose their notifications.
Scenario: a user shares a chat with userA directly and groupB simultaneously. GroupB's member lookup hits a transient DB error. UserA does not receive a notification even though their share succeeded.
Fix: log the group error and continue collecting from remaining groups rather than returning early. (Mafuuu)
🤖
There was a problem hiding this comment.
🤖 This comment was written by Coder Agent on behalf of Danielle Maywood 🤖
Also obsolete after the redesign. There is no longer a per-group loop that can exit early; direct and group recipients are computed together in a single statement, so the partial-collection scenario cannot occur. If that query fails, notifyChatShared returns an error that the handler logs as a warning; the ACL update itself already committed and the handler still returns 204.
| '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.', |
There was a problem hiding this comment.
P3 [CRF-9] Chat titles are free-text phrases. The body template renders in plain text as:
alice shared the chat Onboarding kickoff with you.
A title like "how to fix the API issue with your team" renders as "alice shared the chat how to fix the API issue with your team with you." and the reader cannot tell where the title ends.
The workspace deletion template quotes its free-text reason field with "**{{.Labels.reason}}**". The same treatment works here:
E'{{.Labels.initiator}} shared the chat "**{{.Labels.chat_title}}**" with you.'
Plain text: alice shared the chat "Onboarding kickoff" with you. (Leorio)
🤖
There was a problem hiding this comment.
🤖 This comment was written by Coder Agent on behalf of Danielle Maywood 🤖
Fixed. The body template now quotes the title: {{.Labels.initiator}} shared the chat "**{{.Labels.chat_title}}**" with you., matching the workspace-deletion reason treatment. Golden files regenerated. (The migration was also renumbered to 000534 during the rebase onto main.)
|
|
||
| var eg errgroup.Group | ||
| for _, userID := range userIDs { | ||
| userID := userID |
There was a problem hiding this comment.
Nit [CRF-2] userID := userID is unnecessary since Go 1.22. The project uses Go 1.24+ (per go.mod). The range loop variable is already per-iteration scoped. (Netero, Luffy)
🤖
There was a problem hiding this comment.
🤖 This comment was written by Coder Agent on behalf of Danielle Maywood 🤖
Done. The redundant userID := userID shadow is removed; the range variable is used directly inside eg.Go.
| rw.WriteHeader(http.StatusNoContent) | ||
| } | ||
|
|
||
| func (api *API) notifyChatShared(ctx context.Context, store database.Store, chat database.Chat, initiatorID uuid.UUID, req codersdk.UpdateChatACL) error { |
There was a problem hiding this comment.
Nit [CRF-10] store database.Store parameter breaks the notify* method pattern. Every other notify* method on *API (notifyTemplateDeleted, notifyUsersOfTemplateDeprecation, notifyUserStatusChanged, notifyWorkspaceCreated, etc.) uses api.Database directly. The sole caller (line 204) passes api.Database. Drop the parameter and use api.Database inline. (Gon)
🤖
There was a problem hiding this comment.
🤖 This comment was written by Coder Agent on behalf of Danielle Maywood 🤖
Done. notifyChatShared no longer takes a store parameter and uses api.Database directly, matching the other notify* methods on *API.
Adds a system notification template for chats shared through chat ACL updates.
When a chat is shared with users or groups, coderd now enqueues chat share notifications after the ACL update succeeds. The notification links recipients to
/agents/{chatID}and includes inbox fallback and golden coverage.Note
🤖 This PR was written by Coder Agent on behalf of Danielle Maywood