Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DELETE FROM notification_templates WHERE id = 'b789bd75-d7c6-4cab-9757-1147ab184903';
27 changes: 27 additions & 0 deletions coderd/database/migrations/000538_chat_shared_notification.up.sql
Original file line number Diff line number Diff line change
@@ -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
);
68 changes: 68 additions & 0 deletions coderd/exp_chats_acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package coderd
import (
"context"
"database/sql"
"errors"
"maps"
"net/http"
"slices"

"github.com/google/uuid"
"golang.org/x/xerrors"
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -199,9 +206,70 @@ func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) {
return
}

Comment thread
DanielleMaywood marked this conversation as resolved.
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)
}
Comment thread
DanielleMaywood marked this conversation as resolved.
if len(recipientIDs) == 0 {
return 0, nil
}

Comment thread
DanielleMaywood marked this conversation as resolved.
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 {
Comment thread
DanielleMaywood marked this conversation as resolved.
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 {
Expand Down
87 changes: 87 additions & 0 deletions coderd/exp_chats_acl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -28,8 +30,10 @@ func TestChatACLSharingLifecycle(t *testing.T) {

ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
notifyEnq := &notificationstest.FakeEnqueuer{}
client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
opts.Auditor = mAudit
opts.NotificationsEnqueuer = notifyEnq
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
Expand Down Expand Up @@ -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)))
Comment thread
DanielleMaywood marked this conversation as resolved.

acl, err := client.GetChatACL(ctx, chat.ID)
require.NoError(t, err)
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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 := &notificationstest.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()

Expand Down
1 change: 1 addition & 0 deletions coderd/inboxnotifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions coderd/inboxnotifications_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
1 change: 1 addition & 0 deletions coderd/notifications/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
15 changes: 15 additions & 0 deletions coderd/notifications/notifications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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

<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>alice shared a chat with you</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
alice shared a chat with you
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>alice shared the chat &ldquo;<strong>Onboarding kickoff</strong>=
&rdquo; with you.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/agents/00000000-0000-0000-0000-000000000=
001" style=3D"display: inline-block; padding: 13px 24px; background-color: =
#020617; color: #f8fafc; text-decoration: none; border-radius: 8px; margin:=
0 4px;">
View chat
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3Db78=
9bd75-d7c6-4cab-9757-1147ab184903" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>

--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
Loading
Loading