From 06ee7b969eea65efb5cc75400d0a3caeb9be36b0 Mon Sep 17 00:00:00 2001 From: Flavien David Date: Tue, 21 May 2024 19:44:42 +0200 Subject: [PATCH] Associate slack user messages to the right user (#5187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Associate slack user messages to the right user * Add index + ✨ * :sparkles: * Better handle whitelisted email addresses. * Handle slack workflows unknown email address * Address comments from review * Add migration + fix script --- connectors/src/connectors/slack/bot.ts | 8 +++ front/create_db_migration_file.sh | 2 +- front/lib/auth.ts | 72 ++++++++++++++++++- front/lib/models/user.ts | 1 + front/lib/resources/key_resource.ts | 15 ++++ front/migrations/db/migration_08.sql | 2 + .../conversations/[cId]/messages/index.ts | 15 +++- .../w/[wId]/assistant/conversations/index.ts | 15 +++- types/src/front/lib/dust_api.ts | 37 +++++++--- 9 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 front/migrations/db/migration_08.sql diff --git a/connectors/src/connectors/slack/bot.ts b/connectors/src/connectors/slack/bot.ts index c8ff38731845..53b5b5605a8f 100644 --- a/connectors/src/connectors/slack/bot.ts +++ b/connectors/src/connectors/slack/bot.ts @@ -437,6 +437,10 @@ async function botAnswerMessage( const mesasgeRes = await dustAPI.postUserMessage({ conversationId: lastSlackChatBotMessage.conversationId, message: messageReqBody, + userEmailHeader: + slackChatBotMessage.slackEmail !== "unknown" + ? slackChatBotMessage.slackEmail + : undefined, }); if (mesasgeRes.isErr()) { return new Err(new Error(mesasgeRes.error.message)); @@ -455,6 +459,10 @@ async function botAnswerMessage( visibility: "unlisted", message: messageReqBody, contentFragment: buildContentFragmentRes.value || undefined, + userEmailHeader: + slackChatBotMessage.slackEmail !== "unknown" + ? slackChatBotMessage.slackEmail + : undefined, }); if (convRes.isErr()) { return new Err(new Error(convRes.error.message)); diff --git a/front/create_db_migration_file.sh b/front/create_db_migration_file.sh index 0860c5e424ee..d3fc28df7e0b 100755 --- a/front/create_db_migration_file.sh +++ b/front/create_db_migration_file.sh @@ -56,7 +56,7 @@ diff --unified=0 --color=always main_output.txt current_output.txt # Run diff and extract only SQL statements, ensuring they end with a semicolon. echo "Running diff and extracting SQL statements..." -diff_output=$(diff --unified=0 main_output.txt current_output.txt | awk '/^\+[^+]/ {print substr($0, 2)}') | sed 's/;*$/;/' +diff_output=$(diff --unified=0 main_output.txt current_output.txt | awk '/^\+[^+]/ {print substr($0, 2)}' | sed 's/;*$/;/') if [ -n "$diff_output" ]; then echo "-- Migration created on $current_date" > diff_output.txt echo "$diff_output" >> diff_output.txt diff --git a/front/lib/auth.ts b/front/lib/auth.ts index 89ad4840dce6..a214216a987f 100644 --- a/front/lib/auth.ts +++ b/front/lib/auth.ts @@ -38,6 +38,7 @@ import { FREE_NO_PLAN_DATA } from "@app/lib/plans/free_plans"; import { isUpgraded } from "@app/lib/plans/plan_codes"; import { renderSubscriptionFromModels } from "@app/lib/plans/subscription"; import { getTrialVersionForPlan, isTrial } from "@app/lib/plans/trial"; +import type { KeyAuthType } from "@app/lib/resources/key_resource"; import { KeyResource } from "@app/lib/resources/key_resource"; import { MembershipResource } from "@app/lib/resources/membership_resource"; import { renderLightWorkspaceType } from "@app/lib/workspace"; @@ -60,11 +61,12 @@ const DUST_INTERNAL_EMAIL_REGEXP = /^[^@]+@dust\.tt$/; * workspace oriented. Use `getUserFromSession` if needed. */ export class Authenticator { - _workspace: Workspace | null; - _user: User | null; + _flags: WhitelistableFeature[]; + _key?: KeyAuthType; _role: RoleType; _subscription: SubscriptionType | null; - _flags: WhitelistableFeature[]; + _user: User | null; + _workspace: Workspace | null; // Should only be called from the static methods below. constructor({ @@ -73,18 +75,21 @@ export class Authenticator { role, subscription, flags, + key, }: { workspace?: Workspace | null; user?: User | null; role: RoleType; subscription?: SubscriptionType | null; flags: WhitelistableFeature[]; + key?: KeyAuthType; }) { this._workspace = workspace || null; this._user = user || null; this._role = role; this._subscription = subscription || null; this._flags = flags; + this._key = key; } /** @@ -275,6 +280,7 @@ export class Authenticator { role, subscription, flags, + key: key.toAuthJSON(), }), keyWorkspaceId: keyWorkspace.sId, }; @@ -358,6 +364,62 @@ export class Authenticator { }); } + /** + * Exchanges an Authenticator associated with a system key for one associated with a user. + * + * /!\ This function should only be used with Authenticators that are associated with a system key. + * + * @param auth + * @param param1 + * @returns + */ + async exchangeSystemKeyForUserAuthByEmail( + auth: Authenticator, + { userEmail }: { userEmail: string } + ): Promise { + if (!auth.isSystemKey()) { + throw new Error("Provided authenticator does not have a system key."); + } + + const owner = auth.workspace(); + if (!owner) { + throw new Error("Workspace not found."); + } + + const user = await User.findOne({ + where: { + email: userEmail, + }, + }); + // If the user does not exist (e.g., whitelisted email addresses), + // simply ignore and return null. + if (!user) { + return null; + } + + // Verify that the user has an active membership in the specified workspace. + const activeMembership = + await MembershipResource.getActiveMembershipOfUserInWorkspace({ + user: renderUserType(user), + workspace: owner, + }); + // If the user does not have an active membership in the workspace, + // simply ignore and return null. + if (!activeMembership) { + return null; + } + + return new Authenticator({ + flags: auth._flags, + key: auth._key, + // We limit scope to a user role. + role: "user", + user, + subscription: auth._subscription, + workspace: auth._workspace, + }); + } + role(): RoleType { return this._role; } @@ -374,6 +436,10 @@ export class Authenticator { return isAdmin(this.workspace()); } + isSystemKey(): boolean { + return !!this._key?.isSystem; + } + workspace(): WorkspaceType | null { return this._workspace ? { diff --git a/front/lib/models/user.ts b/front/lib/models/user.ts index d9bea411b5ee..6d83292573b0 100644 --- a/front/lib/models/user.ts +++ b/front/lib/models/user.ts @@ -103,6 +103,7 @@ User.init( { fields: ["provider", "providerId"] }, { fields: ["auth0Sub"], unique: true, concurrently: true }, { unique: true, fields: ["sId"] }, + { fields: ["email"] }, ], } ); diff --git a/front/lib/resources/key_resource.ts b/front/lib/resources/key_resource.ts index 471b0e8eeda6..b03383b128d2 100644 --- a/front/lib/resources/key_resource.ts +++ b/front/lib/resources/key_resource.ts @@ -18,6 +18,12 @@ import { KeyModel } from "@app/lib/resources/storage/models/keys"; import type { ReadonlyAttributesType } from "@app/lib/resources/storage/types"; import { new_id } from "@app/lib/utils"; +export interface KeyAuthType { + id: ModelId; + name: string | null; + isSystem: boolean; +} + // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export interface KeyResource extends ReadonlyAttributesType {} // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging @@ -167,6 +173,15 @@ export class KeyResource extends BaseResource { }; } + // Use to serialize a KeyResource in the Authenticator. + toAuthJSON(): KeyAuthType { + return { + id: this.id, + name: this.name, + isSystem: this.isSystem, + }; + } + get isActive() { return this.status === "active"; } diff --git a/front/migrations/db/migration_08.sql b/front/migrations/db/migration_08.sql new file mode 100644 index 000000000000..e87cd2b016c8 --- /dev/null +++ b/front/migrations/db/migration_08.sql @@ -0,0 +1,2 @@ +-- Migration created on May 21, 2024 +CREATE INDEX "users_email" ON "users" ("email"); diff --git a/front/pages/api/v1/w/[wId]/assistant/conversations/[cId]/messages/index.ts b/front/pages/api/v1/w/[wId]/assistant/conversations/[cId]/messages/index.ts index 34711a0be892..a8e758199a56 100644 --- a/front/pages/api/v1/w/[wId]/assistant/conversations/[cId]/messages/index.ts +++ b/front/pages/api/v1/w/[wId]/assistant/conversations/[cId]/messages/index.ts @@ -27,10 +27,12 @@ async function handler( return apiError(req, res, keyRes.error); } - const { auth, keyWorkspaceId } = await Authenticator.fromKey( + const authenticator = await Authenticator.fromKey( keyRes.value, req.query.wId as string ); + let { auth } = authenticator; + const { keyWorkspaceId } = authenticator; if (!auth.isBuilder() || keyWorkspaceId !== req.query.wId) { return apiError(req, res, { @@ -71,6 +73,17 @@ async function handler( const { content, context, mentions, blocking } = bodyValidation.right; + // /!\ This is reserved for internal use! + // If the header "x-api-user-email" is present and valid, + // associate the message with the provided user email if it belongs to the same workspace. + const userEmailFromHeader = req.headers["x-api-user-email"]; + if (typeof userEmailFromHeader === "string") { + auth = + (await auth.exchangeSystemKeyForUserAuthByEmail(auth, { + userEmail: userEmailFromHeader, + })) ?? auth; + } + const messageRes = await postUserMessageWithPubSub( auth, { diff --git a/front/pages/api/v1/w/[wId]/assistant/conversations/index.ts b/front/pages/api/v1/w/[wId]/assistant/conversations/index.ts index 2245ba744d6b..6871369ee7ed 100644 --- a/front/pages/api/v1/w/[wId]/assistant/conversations/index.ts +++ b/front/pages/api/v1/w/[wId]/assistant/conversations/index.ts @@ -33,10 +33,12 @@ async function handler( return apiError(req, res, keyRes.error); } - const { auth, keyWorkspaceId } = await Authenticator.fromKey( + const authenticator = await Authenticator.fromKey( keyRes.value, req.query.wId as string ); + let { auth } = authenticator; + const { keyWorkspaceId } = authenticator; if (!auth.isBuilder() || keyWorkspaceId !== req.query.wId) { return apiError(req, res, { @@ -96,6 +98,17 @@ async function handler( } } + // /!\ This is reserved for internal use! + // If the header "x-api-user-email" is present and valid, + // associate the message with the provided user email if it belongs to the same workspace. + const userEmailFromHeader = req.headers["x-api-user-email"]; + if (typeof userEmailFromHeader === "string") { + auth = + (await auth.exchangeSystemKeyForUserAuthByEmail(auth, { + userEmail: userEmailFromHeader, + })) ?? auth; + } + let conversation = await createConversation(auth, { title, visibility, diff --git a/types/src/front/lib/dust_api.ts b/types/src/front/lib/dust_api.ts index a34de00b843f..1d00d83f5eea 100644 --- a/types/src/front/lib/dust_api.ts +++ b/types/src/front/lib/dust_api.ts @@ -508,27 +508,36 @@ export class DustAPI { } // When creating a conversation with a user message, the API returns only after the user message - // was created (and if applicable the assocaited agent messages). + // was created (and if applicable the associated agent messages). async createConversation({ title, visibility, message, contentFragment, blocking = false, - }: t.TypeOf): Promise< + userEmailHeader, + }: t.TypeOf & { + userEmailHeader?: string; + }): Promise< DustAPIResponse<{ conversation: ConversationType; message: UserMessageType; }> > { + const headers: Record = { + Authorization: `Bearer ${this._credentials.apiKey}`, + "Content-Type": "application/json", + }; + + if (userEmailHeader) { + headers["x-api-user-email"] = userEmailHeader; + } + const res = await fetch( `${this.apiUrl()}/api/v1/w/${this.workspaceId()}/assistant/conversations`, { method: "POST", - headers: { - Authorization: `Bearer ${this._credentials.apiKey}`, - "Content-Type": "application/json", - }, + headers, body: JSON.stringify({ title, visibility, @@ -545,18 +554,26 @@ export class DustAPI { async postUserMessage({ conversationId, message, + userEmailHeader, }: { conversationId: string; message: t.TypeOf; + userEmailHeader?: string; }): Promise> { + const headers: Record = { + Authorization: `Bearer ${this._credentials.apiKey}`, + "Content-Type": "application/json", + }; + + if (userEmailHeader) { + headers["x-api-user-email"] = userEmailHeader; + } + const res = await fetch( `${this.apiUrl()}/api/v1/w/${this.workspaceId()}/assistant/conversations/${conversationId}/messages`, { method: "POST", - headers: { - Authorization: `Bearer ${this._credentials.apiKey}`, - "Content-Type": "application/json", - }, + headers, body: JSON.stringify({ ...message, }),