Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5d31eca2 | ||
|
|
3b109ec14e | ||
|
|
7781dd8457 | ||
|
|
e5ed2889ed | ||
|
|
026b8b5c34 | ||
|
|
5a6813d9e0 |
@@ -3008,6 +3008,7 @@ type Query {
|
||||
findManyAgents: [Agent!]!
|
||||
findOneAgent(input: AgentIdInput!): Agent!
|
||||
getRoles: [Role!]!
|
||||
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
|
||||
getToolIndex: [ToolIndexEntry!]!
|
||||
getToolInputSchema(toolName: String!): JSON
|
||||
webhooks: [Webhook!]!
|
||||
@@ -3027,7 +3028,6 @@ type Query {
|
||||
getViewGroup(id: String!): ViewGroup
|
||||
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
|
||||
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
|
||||
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
|
||||
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
|
||||
minimalMetadata: MinimalMetadata!
|
||||
appConnections(filter: ListAppConnectionsInput): [AppConnection!]!
|
||||
@@ -3262,6 +3262,7 @@ type Mutation {
|
||||
upsertRowLevelPermissionPredicates(input: UpsertRowLevelPermissionPredicatesInput!): UpsertRowLevelPermissionPredicatesResult!
|
||||
assignRoleToAgent(agentId: UUID!, roleId: UUID!): Boolean!
|
||||
removeRoleFromAgent(agentId: UUID!): Boolean!
|
||||
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
|
||||
runAgent(input: RunAgentInput!): RunAgentResult!
|
||||
createWebhook(input: CreateWebhookInput!): Webhook!
|
||||
updateWebhook(input: UpdateWebhookInput!): Webhook!
|
||||
@@ -3280,7 +3281,6 @@ type Mutation {
|
||||
updateMessageChannel(input: UpdateMessageChannelInput!): MessageChannel!
|
||||
createEmailGroupChannel(input: CreateEmailGroupChannelInput!): CreateEmailGroupChannelOutput!
|
||||
deleteEmailGroupChannel(id: UUID!): MessageChannel!
|
||||
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
|
||||
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
|
||||
createChatThread: AgentChatThread!
|
||||
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
|
||||
|
||||
@@ -2614,6 +2614,7 @@ export interface Query {
|
||||
findManyAgents: Agent[]
|
||||
findOneAgent: Agent
|
||||
getRoles: Role[]
|
||||
myConnectedAccounts: ConnectedAccountPublicDTO[]
|
||||
getToolIndex: ToolIndexEntry[]
|
||||
getToolInputSchema?: Scalars['JSON']
|
||||
webhooks: Webhook[]
|
||||
@@ -2624,7 +2625,6 @@ export interface Query {
|
||||
getViewGroup?: ViewGroup
|
||||
myMessageFolders: MessageFolder[]
|
||||
myMessageChannels: MessageChannel[]
|
||||
myConnectedAccounts: ConnectedAccountPublicDTO[]
|
||||
myCalendarChannels: CalendarChannel[]
|
||||
minimalMetadata: MinimalMetadata
|
||||
appConnections: AppConnection[]
|
||||
@@ -2786,6 +2786,7 @@ export interface Mutation {
|
||||
upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult
|
||||
assignRoleToAgent: Scalars['Boolean']
|
||||
removeRoleFromAgent: Scalars['Boolean']
|
||||
deleteConnectedAccount: ConnectedAccountPublicDTO
|
||||
runAgent: RunAgentResult
|
||||
createWebhook: Webhook
|
||||
updateWebhook: Webhook
|
||||
@@ -2804,7 +2805,6 @@ export interface Mutation {
|
||||
updateMessageChannel: MessageChannel
|
||||
createEmailGroupChannel: CreateEmailGroupChannelOutput
|
||||
deleteEmailGroupChannel: MessageChannel
|
||||
deleteConnectedAccount: ConnectedAccountPublicDTO
|
||||
updateCalendarChannel: CalendarChannel
|
||||
createChatThread: AgentChatThread
|
||||
sendChatMessage: SendChatMessageResult
|
||||
@@ -5673,6 +5673,7 @@ export interface QueryGenqlSelection{
|
||||
findManyAgents?: AgentGenqlSelection
|
||||
findOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
|
||||
getRoles?: RoleGenqlSelection
|
||||
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
|
||||
getToolIndex?: ToolIndexEntryGenqlSelection
|
||||
getToolInputSchema?: { __args: {toolName: Scalars['String']} }
|
||||
webhooks?: WebhookGenqlSelection
|
||||
@@ -5689,7 +5690,6 @@ export interface QueryGenqlSelection{
|
||||
getViewGroup?: (ViewGroupGenqlSelection & { __args: {id: Scalars['String']} })
|
||||
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
|
||||
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
|
||||
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
|
||||
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
|
||||
minimalMetadata?: MinimalMetadataGenqlSelection
|
||||
appConnections?: (AppConnectionGenqlSelection & { __args?: {filter?: (ListAppConnectionsInput | null)} })
|
||||
@@ -5874,6 +5874,7 @@ export interface MutationGenqlSelection{
|
||||
upsertRowLevelPermissionPredicates?: (UpsertRowLevelPermissionPredicatesResultGenqlSelection & { __args: {input: UpsertRowLevelPermissionPredicatesInput} })
|
||||
assignRoleToAgent?: { __args: {agentId: Scalars['UUID'], roleId: Scalars['UUID']} }
|
||||
removeRoleFromAgent?: { __args: {agentId: Scalars['UUID']} }
|
||||
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
runAgent?: (RunAgentResultGenqlSelection & { __args: {input: RunAgentInput} })
|
||||
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
|
||||
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
|
||||
@@ -5892,7 +5893,6 @@ export interface MutationGenqlSelection{
|
||||
updateMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: UpdateMessageChannelInput} })
|
||||
createEmailGroupChannel?: (CreateEmailGroupChannelOutputGenqlSelection & { __args: {input: CreateEmailGroupChannelInput} })
|
||||
deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
|
||||
createChatThread?: AgentChatThreadGenqlSelection
|
||||
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
|
||||
|
||||
@@ -6153,6 +6153,9 @@ export default {
|
||||
"getRoles": [
|
||||
29
|
||||
],
|
||||
"myConnectedAccounts": [
|
||||
271
|
||||
],
|
||||
"getToolIndex": [
|
||||
277
|
||||
],
|
||||
@@ -6232,9 +6235,6 @@ export default {
|
||||
]
|
||||
}
|
||||
],
|
||||
"myConnectedAccounts": [
|
||||
271
|
||||
],
|
||||
"myCalendarChannels": [
|
||||
305,
|
||||
{
|
||||
@@ -7714,6 +7714,15 @@ export default {
|
||||
]
|
||||
}
|
||||
],
|
||||
"deleteConnectedAccount": [
|
||||
271,
|
||||
{
|
||||
"id": [
|
||||
3,
|
||||
"UUID!"
|
||||
]
|
||||
}
|
||||
],
|
||||
"runAgent": [
|
||||
279,
|
||||
{
|
||||
@@ -7876,15 +7885,6 @@ export default {
|
||||
]
|
||||
}
|
||||
],
|
||||
"deleteConnectedAccount": [
|
||||
271,
|
||||
{
|
||||
"id": [
|
||||
3,
|
||||
"UUID!"
|
||||
]
|
||||
}
|
||||
],
|
||||
"updateCalendarChannel": [
|
||||
305,
|
||||
{
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { ReassignWorkflowCreatorToEmailAccountOwnerCommand } from 'src/database/commands/upgrade-version-command/2-11/2-11-workspace-command-1799100000000-reassign-workflow-creator-to-email-account-owner.command';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ConnectedAccountEntity, UserWorkspaceEntity]),
|
||||
WorkspaceIteratorModule,
|
||||
],
|
||||
providers: [ReassignWorkflowCreatorToEmailAccountOwnerCommand],
|
||||
})
|
||||
export class V2_11_UpgradeVersionCommandModule {}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Command } from 'nest-commander';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import {
|
||||
WorkflowVersionStatus,
|
||||
type WorkflowVersionWorkspaceEntity,
|
||||
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
|
||||
import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
|
||||
const EMAIL_ACTION_TYPES: WorkflowActionType[] = [
|
||||
WorkflowActionType.SEND_EMAIL,
|
||||
WorkflowActionType.DRAFT_EMAIL,
|
||||
];
|
||||
|
||||
@RegisteredWorkspaceCommand('2.11.0', 1799100000000)
|
||||
@Command({
|
||||
name: 'upgrade:2-11:reassign-workflow-creator-to-email-account-owner',
|
||||
description:
|
||||
'Reassign the creator of active workflows whose email steps use a user-visibility connected account owned by another member, so workflow runs can resolve the account through the acting-user access check',
|
||||
})
|
||||
export class ReassignWorkflowCreatorToEmailAccountOwnerCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
@InjectRepository(ConnectedAccountEntity)
|
||||
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
const accountIdsByWorkflowId =
|
||||
await this.getEmailStepAccountIdsByWorkflowId(workspaceId);
|
||||
|
||||
if (accountIdsByWorkflowId.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const referencedAccountIds = [
|
||||
...new Set([...accountIdsByWorkflowId.values()].flat()),
|
||||
];
|
||||
|
||||
const userVisibilityAccounts = await this.connectedAccountRepository.find({
|
||||
where: {
|
||||
id: In(referencedAccountIds),
|
||||
workspaceId,
|
||||
visibility: 'user',
|
||||
},
|
||||
});
|
||||
|
||||
if (userVisibilityAccounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountById = new Map(
|
||||
userVisibilityAccounts.map((account) => [account.id, account]),
|
||||
);
|
||||
|
||||
const workflowRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkflowWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workflow',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const workflows = await workflowRepository.find({
|
||||
where: { id: In([...accountIdsByWorkflowId.keys()]) },
|
||||
});
|
||||
|
||||
const memberByUserWorkspaceId = await this.getMemberByUserWorkspaceId({
|
||||
workspaceId,
|
||||
userWorkspaceIds: userVisibilityAccounts
|
||||
.map((account) => account.userWorkspaceId)
|
||||
.filter(isDefined),
|
||||
});
|
||||
|
||||
let reassignedCount = 0;
|
||||
|
||||
for (const workflow of workflows) {
|
||||
const ownerUserWorkspaceIds = [
|
||||
...new Set(
|
||||
(accountIdsByWorkflowId.get(workflow.id) ?? [])
|
||||
.map((accountId) => accountById.get(accountId)?.userWorkspaceId)
|
||||
.filter(isDefined),
|
||||
),
|
||||
];
|
||||
|
||||
if (ownerUserWorkspaceIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ownerUserWorkspaceIds.length > 1) {
|
||||
this.logger.warn(
|
||||
`Workflow ${workflow.id} in workspace ${workspaceId} references user-visibility accounts of ${ownerUserWorkspaceIds.length} different owners; skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ownerMember = memberByUserWorkspaceId.get(ownerUserWorkspaceIds[0]);
|
||||
|
||||
if (!isDefined(ownerMember)) {
|
||||
this.logger.warn(
|
||||
`Workflow ${workflow.id} in workspace ${workspaceId} references an account whose owner has no workspace member; skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (workflow.createdBy?.workspaceMemberId === ownerMember.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ownerFullName =
|
||||
`${ownerMember.name?.firstName ?? ''} ${ownerMember.name?.lastName ?? ''}`.trim();
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Reassigning creator of workflow ${workflow.id} in workspace ${workspaceId} to account owner member ${ownerMember.id}`,
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await workflowRepository.update(workflow.id, {
|
||||
createdBy: {
|
||||
...workflow.createdBy,
|
||||
workspaceMemberId: ownerMember.id,
|
||||
name: ownerFullName,
|
||||
},
|
||||
});
|
||||
reassignedCount++;
|
||||
}
|
||||
|
||||
if (reassignedCount > 0) {
|
||||
this.logger.log(
|
||||
`Reassigned creator on ${reassignedCount} workflow(s) for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getEmailStepAccountIdsByWorkflowId(
|
||||
workspaceId: string,
|
||||
): Promise<Map<string, string[]>> {
|
||||
const workflowVersionRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkflowVersionWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workflowVersion',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const activeVersions = await workflowVersionRepository.find({
|
||||
where: { status: WorkflowVersionStatus.ACTIVE },
|
||||
});
|
||||
|
||||
const accountIdsByWorkflowId = new Map<string, string[]>();
|
||||
|
||||
for (const version of activeVersions) {
|
||||
if (!Array.isArray(version.steps)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const accountIds = version.steps
|
||||
.filter((step: WorkflowAction) =>
|
||||
EMAIL_ACTION_TYPES.includes(step.type),
|
||||
)
|
||||
.map(
|
||||
(step: WorkflowAction) =>
|
||||
(step.settings?.input as { connectedAccountId?: string })
|
||||
?.connectedAccountId,
|
||||
)
|
||||
.filter(
|
||||
(accountId): accountId is string =>
|
||||
isNonEmptyString(accountId) && isValidUuid(accountId),
|
||||
);
|
||||
|
||||
if (accountIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
accountIdsByWorkflowId.set(version.workflowId, [
|
||||
...(accountIdsByWorkflowId.get(version.workflowId) ?? []),
|
||||
...accountIds,
|
||||
]);
|
||||
}
|
||||
|
||||
return accountIdsByWorkflowId;
|
||||
}
|
||||
|
||||
private async getMemberByUserWorkspaceId({
|
||||
workspaceId,
|
||||
userWorkspaceIds,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
userWorkspaceIds: string[];
|
||||
}): Promise<Map<string, WorkspaceMemberWorkspaceEntity>> {
|
||||
if (userWorkspaceIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const userWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: { id: In(userWorkspaceIds), workspaceId },
|
||||
});
|
||||
|
||||
const workspaceMemberRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workspaceMember',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const members = await workspaceMemberRepository.find({
|
||||
where: { userId: In(userWorkspaces.map((uw) => uw.userId)) },
|
||||
});
|
||||
|
||||
const memberByUserId = new Map(
|
||||
members.map((member) => [member.userId, member]),
|
||||
);
|
||||
|
||||
return new Map(
|
||||
userWorkspaces
|
||||
.map(
|
||||
(userWorkspace): [string, WorkspaceMemberWorkspaceEntity] | null => {
|
||||
const member = memberByUserId.get(userWorkspace.userId);
|
||||
|
||||
return isDefined(member) ? [userWorkspace.id, member] : null;
|
||||
},
|
||||
)
|
||||
.filter(isDefined),
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -13,6 +13,7 @@ import { V2_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
|
||||
import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-8/2-8-upgrade-version-command.module';
|
||||
import { V2_9_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module';
|
||||
import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-10/2-10-upgrade-version-command.module';
|
||||
import { V2_11_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-11/2-11-upgrade-version-command.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -29,6 +30,7 @@ import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
|
||||
V2_8_UpgradeVersionCommandModule,
|
||||
V2_9_UpgradeVersionCommandModule,
|
||||
V2_10_UpgradeVersionCommandModule,
|
||||
V2_11_UpgradeVersionCommandModule,
|
||||
],
|
||||
})
|
||||
export class WorkspaceCommandProviderModule {}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool
|
||||
import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool';
|
||||
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
|
||||
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
|
||||
@@ -26,7 +26,8 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
|
||||
imports: [
|
||||
MessagingImportManagerModule,
|
||||
MessagingSendManagerModule,
|
||||
TypeOrmModule.forFeature([FileEntity, ConnectedAccountEntity]),
|
||||
TypeOrmModule.forFeature([FileEntity]),
|
||||
ConnectedAccountMetadataModule,
|
||||
ApplicationModule,
|
||||
FeatureFlagModule,
|
||||
FileModule,
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { ConnectedAccountProvider, FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
|
||||
import { type ComposeEmailParams } from 'src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type';
|
||||
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
|
||||
|
||||
const WORKSPACE_ID = randomUUID();
|
||||
const ALICE_USER_WORKSPACE_ID = randomUUID();
|
||||
const BOB_USER_WORKSPACE_ID = randomUUID();
|
||||
|
||||
const ALICE_ACCOUNT_ID = randomUUID();
|
||||
const BOB_ACCOUNT_ID = randomUUID();
|
||||
const SHARED_ACCOUNT_ID = randomUUID();
|
||||
|
||||
// In-memory connected accounts that mimic the rows TypeORM would return.
|
||||
type FakeAccount = Partial<ConnectedAccountEntity> & { id: string };
|
||||
|
||||
const aliceUserPrivateAccount: FakeAccount = {
|
||||
id: ALICE_ACCOUNT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: ALICE_USER_WORKSPACE_ID,
|
||||
visibility: 'user',
|
||||
handle: 'alice@example.com',
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
connectionParameters: null,
|
||||
messageChannels: [{ id: 'mc-alice', handle: 'alice@example.com' }] as never,
|
||||
};
|
||||
|
||||
const bobUserPrivateAccount: FakeAccount = {
|
||||
id: BOB_ACCOUNT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: BOB_USER_WORKSPACE_ID,
|
||||
visibility: 'user',
|
||||
handle: 'bob@example.com',
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
connectionParameters: null,
|
||||
messageChannels: [{ id: 'mc-bob', handle: 'bob@example.com' }] as never,
|
||||
};
|
||||
|
||||
// Workspace-visibility account (owned by Alice but shared with the workspace).
|
||||
const sharedWorkspaceAccount: FakeAccount = {
|
||||
id: SHARED_ACCOUNT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: ALICE_USER_WORKSPACE_ID,
|
||||
visibility: 'workspace',
|
||||
handle: 'team@example.com',
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
connectionParameters: null,
|
||||
messageChannels: [{ id: 'mc-shared', handle: 'team@example.com' }] as never,
|
||||
};
|
||||
|
||||
// Mirrors ConnectedAccountMetadataService's visibility rule: an account is
|
||||
// usable by a caller when it is workspace-shared, or it belongs to the caller's
|
||||
// own user workspace. The authoritative scoping is proven directly against the
|
||||
// repository in connected-account-metadata.service.spec.ts; here we stub the
|
||||
// finders so these tests focus on the composer's own selection/rejection logic.
|
||||
const isVisibleToCaller = (
|
||||
account: FakeAccount,
|
||||
userWorkspaceId: string | undefined,
|
||||
): boolean =>
|
||||
account.visibility === 'workspace' ||
|
||||
(isDefined(userWorkspaceId) && account.userWorkspaceId === userWorkspaceId);
|
||||
|
||||
const buildComposeParams = (
|
||||
overrides: Partial<ComposeEmailParams> = {},
|
||||
): ComposeEmailParams => ({
|
||||
recipients: { to: 'recipient@example.com' },
|
||||
subject: 'Hello',
|
||||
body: '<p>Hello</p>',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('EmailComposerService - connected account authorization', () => {
|
||||
let service: EmailComposerService;
|
||||
let accounts: FakeAccount[];
|
||||
|
||||
beforeEach(async () => {
|
||||
accounts = [
|
||||
aliceUserPrivateAccount,
|
||||
bobUserPrivateAccount,
|
||||
sharedWorkspaceAccount,
|
||||
];
|
||||
|
||||
const mockConnectedAccountMetadataService = {
|
||||
findAccessibleConnectedAccountById: jest.fn(
|
||||
({ id, userWorkspaceId, workspaceId }) =>
|
||||
Promise.resolve(
|
||||
accounts.find(
|
||||
(account) =>
|
||||
account.id === id &&
|
||||
account.workspaceId === workspaceId &&
|
||||
isVisibleToCaller(account, userWorkspaceId),
|
||||
) ?? null,
|
||||
),
|
||||
),
|
||||
findById: jest.fn(({ id, workspaceId }) =>
|
||||
Promise.resolve(
|
||||
accounts.find(
|
||||
(account) =>
|
||||
account.id === id && account.workspaceId === workspaceId,
|
||||
) ?? null,
|
||||
),
|
||||
),
|
||||
findAccessibleConnectedAccounts: jest.fn(
|
||||
({ userWorkspaceId, workspaceId }) => {
|
||||
const accessibleAccounts = accounts.filter(
|
||||
(account) =>
|
||||
account.workspaceId === workspaceId &&
|
||||
isVisibleToCaller(account, userWorkspaceId),
|
||||
);
|
||||
|
||||
return Promise.resolve({
|
||||
userConnectedAccounts: accessibleAccounts.filter(
|
||||
(account) => account.userWorkspaceId === userWorkspaceId,
|
||||
),
|
||||
workspaceSharedConnectedAccounts: accessibleAccounts.filter(
|
||||
(account) => account.userWorkspaceId !== userWorkspaceId,
|
||||
),
|
||||
});
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
const mockGlobalWorkspaceOrmManager = {
|
||||
executeInWorkspaceContext: jest
|
||||
.fn()
|
||||
.mockImplementation((fn: () => unknown) => fn()),
|
||||
getRepository: jest.fn(),
|
||||
};
|
||||
|
||||
const mockFileRepository = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const mockFileService = {
|
||||
getFileStreamById: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EmailComposerService,
|
||||
{
|
||||
provide: GlobalWorkspaceOrmManager,
|
||||
useValue: mockGlobalWorkspaceOrmManager,
|
||||
},
|
||||
{
|
||||
provide: ConnectedAccountMetadataService,
|
||||
useValue: mockConnectedAccountMetadataService,
|
||||
},
|
||||
{
|
||||
provide: getWorkspaceScopedRepositoryToken(FileEntity),
|
||||
useValue: mockFileRepository,
|
||||
},
|
||||
{
|
||||
provide: FileService,
|
||||
useValue: mockFileService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EmailComposerService>(EmailComposerService);
|
||||
});
|
||||
|
||||
const compose = (params: ComposeEmailParams, context: ToolExecutionContext) =>
|
||||
service.composeEmail(params, context, {
|
||||
attachmentsFileFolder: FileFolder.Workflow,
|
||||
});
|
||||
|
||||
describe('explicit connectedAccountId', () => {
|
||||
it("should reject sending from another member's user-private account (impersonation)", async () => {
|
||||
const bobContext: ToolExecutionContext = {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: BOB_USER_WORKSPACE_ID,
|
||||
};
|
||||
|
||||
// Bob asks to send FROM Alice's private account.
|
||||
await expect(
|
||||
compose(
|
||||
buildComposeParams({
|
||||
connectedAccountId: aliceUserPrivateAccount.id,
|
||||
}),
|
||||
bobContext,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
`Connected Account '${aliceUserPrivateAccount.id}' is private to another workspace member and cannot be used in this context`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should report not found for an account id that does not exist', async () => {
|
||||
const bobContext: ToolExecutionContext = {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: BOB_USER_WORKSPACE_ID,
|
||||
};
|
||||
const ghostAccountId = randomUUID();
|
||||
|
||||
await expect(
|
||||
compose(
|
||||
buildComposeParams({ connectedAccountId: ghostAccountId }),
|
||||
bobContext,
|
||||
),
|
||||
).rejects.toThrow(`Connected Account '${ghostAccountId}' not found`);
|
||||
});
|
||||
|
||||
it('should allow a member to use their own user-private account', async () => {
|
||||
const bobContext: ToolExecutionContext = {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: BOB_USER_WORKSPACE_ID,
|
||||
};
|
||||
|
||||
const result = await compose(
|
||||
buildComposeParams({ connectedAccountId: bobUserPrivateAccount.id }),
|
||||
bobContext,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.connectedAccount.id).toBe(bobUserPrivateAccount.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow any member to use a workspace-visibility account', async () => {
|
||||
const bobContext: ToolExecutionContext = {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: BOB_USER_WORKSPACE_ID,
|
||||
};
|
||||
|
||||
const result = await compose(
|
||||
buildComposeParams({ connectedAccountId: sharedWorkspaceAccount.id }),
|
||||
bobContext,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.connectedAccount.id).toBe(sharedWorkspaceAccount.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('omitted connectedAccountId (default selection)', () => {
|
||||
it("should not silently default to another member's user-private account", async () => {
|
||||
// Only Alice's user-private account exists; Bob has none of his own.
|
||||
accounts = [aliceUserPrivateAccount];
|
||||
|
||||
const bobContext: ToolExecutionContext = {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: BOB_USER_WORKSPACE_ID,
|
||||
};
|
||||
|
||||
await expect(compose(buildComposeParams(), bobContext)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should prefer the caller own account over a workspace-visibility account', async () => {
|
||||
const bobContext: ToolExecutionContext = {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: BOB_USER_WORKSPACE_ID,
|
||||
};
|
||||
|
||||
const result = await compose(buildComposeParams(), bobContext);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.connectedAccount.userWorkspaceId).toBe(
|
||||
BOB_USER_WORKSPACE_ID,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('system/workflow execution without a user identity', () => {
|
||||
it('should reject a user-private account when no userWorkspaceId is present', async () => {
|
||||
const systemContext: ToolExecutionContext = {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
};
|
||||
|
||||
await expect(
|
||||
compose(
|
||||
buildComposeParams({
|
||||
connectedAccountId: aliceUserPrivateAccount.id,
|
||||
}),
|
||||
systemContext,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
`Connected Account '${aliceUserPrivateAccount.id}' is private to another workspace member and cannot be used in this context`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow a user-private account when the acting user owns it', async () => {
|
||||
const workflowActingAsAliceContext: ToolExecutionContext = {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: ALICE_USER_WORKSPACE_ID,
|
||||
};
|
||||
|
||||
const result = await compose(
|
||||
buildComposeParams({ connectedAccountId: aliceUserPrivateAccount.id }),
|
||||
workflowActingAsAliceContext,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.connectedAccount.id).toBe(
|
||||
aliceUserPrivateAccount.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow a workspace-visibility account when no userWorkspaceId is present', async () => {
|
||||
const systemContext: ToolExecutionContext = {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
};
|
||||
|
||||
const result = await compose(
|
||||
buildComposeParams({ connectedAccountId: sharedWorkspaceAccount.id }),
|
||||
systemContext,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.connectedAccount.id).toBe(sharedWorkspaceAccount.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+83
-58
@@ -1,5 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { toPlainText } from '@react-email/render';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
FileFolder,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { In, LessThanOrEqual, type Repository } from 'typeorm';
|
||||
import { In, LessThanOrEqual } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
@@ -24,7 +23,7 @@ import { type ComposeEmailParams } from 'src/engine/core-modules/tool/tools/emai
|
||||
import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
|
||||
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
|
||||
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
@@ -45,17 +44,21 @@ export class EmailComposerService {
|
||||
|
||||
constructor(
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
@InjectRepository(ConnectedAccountEntity)
|
||||
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
|
||||
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
|
||||
@InjectWorkspaceScopedRepository(FileEntity)
|
||||
private readonly fileRepository: WorkspaceScopedRepository<FileEntity>,
|
||||
private readonly fileService: FileService,
|
||||
) {}
|
||||
|
||||
private async getConnectedAccount(
|
||||
connectedAccountId: string,
|
||||
workspaceId: string,
|
||||
) {
|
||||
private async getConnectedAccount({
|
||||
connectedAccountId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
connectedAccountId: string;
|
||||
workspaceId: string;
|
||||
userWorkspaceId: string | undefined;
|
||||
}) {
|
||||
if (!isValidUuid(connectedAccountId)) {
|
||||
throw new EmailToolException(
|
||||
`Connected Account ID is not a valid UUID`,
|
||||
@@ -63,54 +66,76 @@ export class EmailComposerService {
|
||||
);
|
||||
}
|
||||
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const connectedAccount = await this.connectedAccountRepository.findOne({
|
||||
where: { id: connectedAccountId, workspaceId },
|
||||
const connectedAccount =
|
||||
await this.connectedAccountMetadataService.findAccessibleConnectedAccountById(
|
||||
{
|
||||
id: connectedAccountId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
relations: {
|
||||
messageChannels: {
|
||||
messageFolders: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(connectedAccount)) {
|
||||
const inaccessibleConnectedAccount =
|
||||
await this.connectedAccountMetadataService.findById({
|
||||
id: connectedAccountId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!isDefined(connectedAccount)) {
|
||||
throw new EmailToolException(
|
||||
`Connected Account '${connectedAccountId}' not found`,
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (isDefined(inaccessibleConnectedAccount)) {
|
||||
throw new EmailToolException(
|
||||
`Connected Account '${connectedAccountId}' is private to another workspace member and cannot be used in this context`,
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_ACCESSIBLE,
|
||||
);
|
||||
}
|
||||
|
||||
return connectedAccount;
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
throw new EmailToolException(
|
||||
`Connected Account '${connectedAccountId}' not found`,
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return connectedAccount;
|
||||
}
|
||||
|
||||
private async getOrThrowFirstConnectedAccountId(
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
private async getDefaultConnectedAccountOrThrow({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
userWorkspaceId: string | undefined;
|
||||
}) {
|
||||
const { userConnectedAccounts, workspaceSharedConnectedAccounts } =
|
||||
await this.connectedAccountMetadataService.findAccessibleConnectedAccounts(
|
||||
{
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
relations: {
|
||||
messageChannels: {
|
||||
messageFolders: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const allAccounts = await this.connectedAccountRepository.find({
|
||||
where: { workspaceId },
|
||||
});
|
||||
// Prefer the caller's own account; fall back to a workspace-shared one, but
|
||||
// never silently default to another member's private account.
|
||||
const connectedAccount =
|
||||
userConnectedAccounts[0] ?? workspaceSharedConnectedAccounts[0];
|
||||
|
||||
if (!allAccounts || allAccounts.length === 0) {
|
||||
throw new EmailToolException(
|
||||
'No connected accounts found for this workspace',
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (!isDefined(connectedAccount)) {
|
||||
throw new EmailToolException(
|
||||
'No connected accounts found for this workspace',
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return allAccounts[0].id;
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
return connectedAccount;
|
||||
}
|
||||
|
||||
private normalizeRecipients(parameters: ComposeEmailParams): {
|
||||
@@ -314,9 +339,8 @@ export class EmailComposerService {
|
||||
context: ToolExecutionContext,
|
||||
options: { attachmentsFileFolder: FileFolder },
|
||||
): Promise<EmailComposerResult> {
|
||||
const { workspaceId } = context;
|
||||
const { subject, body, files, inReplyTo } = parameters;
|
||||
let { connectedAccountId } = parameters;
|
||||
const { workspaceId, userWorkspaceId } = context;
|
||||
const { subject, body, files, inReplyTo, connectedAccountId } = parameters;
|
||||
|
||||
let recipients: { to: string[]; cc: string[]; bcc: string[] };
|
||||
|
||||
@@ -352,15 +376,16 @@ export class EmailComposerService {
|
||||
|
||||
const toRecipientsDisplay = recipients.to.join(', ');
|
||||
|
||||
if (!connectedAccountId) {
|
||||
connectedAccountId =
|
||||
await this.getOrThrowFirstConnectedAccountId(workspaceId);
|
||||
}
|
||||
|
||||
const connectedAccount = await this.getConnectedAccount(
|
||||
connectedAccountId,
|
||||
workspaceId,
|
||||
);
|
||||
const connectedAccount = isNonEmptyString(connectedAccountId)
|
||||
? await this.getConnectedAccount({
|
||||
connectedAccountId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
})
|
||||
: await this.getDefaultConnectedAccountOrThrow({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
});
|
||||
|
||||
const messageChannel = connectedAccount.messageChannels.find(
|
||||
(channel) => channel.handle === connectedAccount.handle,
|
||||
@@ -375,14 +400,14 @@ export class EmailComposerService {
|
||||
!isDefined(connectedAccount.connectionParameters?.SMTP)
|
||||
) {
|
||||
throw new EmailToolException(
|
||||
`SMTP is not configured for connected account '${connectedAccountId}'`,
|
||||
`SMTP is not configured for connected account '${connectedAccount.id}'`,
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSmtpOnlyAccount && !isDefined(messageChannel)) {
|
||||
throw new EmailToolException(
|
||||
`No message channel found for connected account '${connectedAccountId}'`,
|
||||
`No message channel found for connected account '${connectedAccount.id}'`,
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
+3
@@ -7,6 +7,7 @@ import { CustomException } from 'src/utils/custom-exception';
|
||||
export enum EmailToolExceptionCode {
|
||||
INVALID_CONNECTED_ACCOUNT_ID = 'INVALID_CONNECTED_ACCOUNT_ID',
|
||||
CONNECTED_ACCOUNT_NOT_FOUND = 'CONNECTED_ACCOUNT_NOT_FOUND',
|
||||
CONNECTED_ACCOUNT_NOT_ACCESSIBLE = 'CONNECTED_ACCOUNT_NOT_ACCESSIBLE',
|
||||
INVALID_EMAIL = 'INVALID_EMAIL',
|
||||
WORKSPACE_ID_NOT_FOUND = 'WORKSPACE_ID_NOT_FOUND',
|
||||
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
|
||||
@@ -22,6 +23,8 @@ const getEmailToolExceptionUserFriendlyMessage = (
|
||||
return msg`Invalid connected account ID.`;
|
||||
case EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
|
||||
return msg`Connected account not found.`;
|
||||
case EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_ACCESSIBLE:
|
||||
return msg`This connected account is private to another workspace member. Ask its owner to share it with the workspace, or pick an account you own.`;
|
||||
case EmailToolExceptionCode.INVALID_EMAIL:
|
||||
return msg`Invalid email address.`;
|
||||
case EmailToolExceptionCode.WORKSPACE_ID_NOT_FOUND:
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service';
|
||||
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
|
||||
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
|
||||
|
||||
const WORKSPACE_ID = randomUUID();
|
||||
const ALICE_USER_WORKSPACE_ID = randomUUID();
|
||||
const BOB_USER_WORKSPACE_ID = randomUUID();
|
||||
|
||||
const ALICE_ACCOUNT_ID = randomUUID();
|
||||
const BOB_ACCOUNT_ID = randomUUID();
|
||||
const SHARED_ACCOUNT_ID = randomUUID();
|
||||
|
||||
type FakeAccount = Partial<ConnectedAccountEntity> & { id: string };
|
||||
|
||||
const aliceUserPrivateAccount: FakeAccount = {
|
||||
id: ALICE_ACCOUNT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: ALICE_USER_WORKSPACE_ID,
|
||||
visibility: 'user',
|
||||
};
|
||||
|
||||
const bobUserPrivateAccount: FakeAccount = {
|
||||
id: BOB_ACCOUNT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: BOB_USER_WORKSPACE_ID,
|
||||
visibility: 'user',
|
||||
};
|
||||
|
||||
const sharedWorkspaceAccount: FakeAccount = {
|
||||
id: SHARED_ACCOUNT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: ALICE_USER_WORKSPACE_ID,
|
||||
visibility: 'workspace',
|
||||
};
|
||||
|
||||
const matchesWhere = (
|
||||
account: FakeAccount,
|
||||
where: Record<string, unknown> | Record<string, unknown>[],
|
||||
): boolean => {
|
||||
const conditions = Array.isArray(where) ? where : [where];
|
||||
|
||||
return conditions.some((condition) =>
|
||||
Object.entries(condition).every(
|
||||
([key, value]) => account[key as keyof FakeAccount] === value,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
describe('ConnectedAccountMetadataService - user-workspace visibility scoping', () => {
|
||||
let service: ConnectedAccountMetadataService;
|
||||
let accounts: FakeAccount[];
|
||||
|
||||
beforeEach(async () => {
|
||||
accounts = [
|
||||
aliceUserPrivateAccount,
|
||||
bobUserPrivateAccount,
|
||||
sharedWorkspaceAccount,
|
||||
];
|
||||
|
||||
const mockConnectedAccountRepository = {
|
||||
findOne: jest.fn(({ where }) =>
|
||||
Promise.resolve(
|
||||
accounts.find((account) => matchesWhere(account, where)) ?? null,
|
||||
),
|
||||
),
|
||||
find: jest.fn(({ where }) =>
|
||||
Promise.resolve(
|
||||
accounts.filter((account) => matchesWhere(account, where)),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ConnectedAccountMetadataService,
|
||||
{
|
||||
provide: getRepositoryToken(ConnectedAccountEntity),
|
||||
useValue: mockConnectedAccountRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CalendarChannelEntity),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(MessageChannelEntity),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: AppOAuthRevokeService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ConnectedAccountMetadataService>(
|
||||
ConnectedAccountMetadataService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('findAccessibleConnectedAccountById', () => {
|
||||
it("should not return another member's user-private account", async () => {
|
||||
const result = await service.findAccessibleConnectedAccountById({
|
||||
id: ALICE_ACCOUNT_ID,
|
||||
userWorkspaceId: BOB_USER_WORKSPACE_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the caller own user-private account', async () => {
|
||||
const result = await service.findAccessibleConnectedAccountById({
|
||||
id: BOB_ACCOUNT_ID,
|
||||
userWorkspaceId: BOB_USER_WORKSPACE_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(result?.id).toBe(BOB_ACCOUNT_ID);
|
||||
});
|
||||
|
||||
it('should return a workspace-visibility account for any member', async () => {
|
||||
const result = await service.findAccessibleConnectedAccountById({
|
||||
id: SHARED_ACCOUNT_ID,
|
||||
userWorkspaceId: BOB_USER_WORKSPACE_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(result?.id).toBe(SHARED_ACCOUNT_ID);
|
||||
});
|
||||
|
||||
it('should reject a user-private account when no userWorkspaceId is present', async () => {
|
||||
const result = await service.findAccessibleConnectedAccountById({
|
||||
id: ALICE_ACCOUNT_ID,
|
||||
userWorkspaceId: undefined,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow a workspace-visibility account when no userWorkspaceId is present', async () => {
|
||||
const result = await service.findAccessibleConnectedAccountById({
|
||||
id: SHARED_ACCOUNT_ID,
|
||||
userWorkspaceId: undefined,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(result?.id).toBe(SHARED_ACCOUNT_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAccessibleConnectedAccounts', () => {
|
||||
it("should split the caller own accounts from workspace-shared ones, never exposing another member's private account", async () => {
|
||||
const { userConnectedAccounts, workspaceSharedConnectedAccounts } =
|
||||
await service.findAccessibleConnectedAccounts({
|
||||
userWorkspaceId: BOB_USER_WORKSPACE_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(userConnectedAccounts.map((account) => account.id)).toEqual([
|
||||
BOB_ACCOUNT_ID,
|
||||
]);
|
||||
expect(
|
||||
workspaceSharedConnectedAccounts.map((account) => account.id),
|
||||
).toEqual([SHARED_ACCOUNT_ID]);
|
||||
});
|
||||
|
||||
it('should return only workspace-shared accounts when no userWorkspaceId is present', async () => {
|
||||
const { userConnectedAccounts, workspaceSharedConnectedAccounts } =
|
||||
await service.findAccessibleConnectedAccounts({
|
||||
userWorkspaceId: undefined,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(userConnectedAccounts).toEqual([]);
|
||||
expect(
|
||||
workspaceSharedConnectedAccounts.map((account) => account.id),
|
||||
).toEqual([SHARED_ACCOUNT_ID]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByUserWorkspaceId', () => {
|
||||
it('should return only the caller own accounts, including their shared ones', async () => {
|
||||
const result = await service.findByUserWorkspaceId({
|
||||
userWorkspaceId: ALICE_USER_WORKSPACE_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
const ids = result.map((account) => account.id);
|
||||
|
||||
expect(ids).toContain(ALICE_ACCOUNT_ID);
|
||||
expect(ids).toContain(SHARED_ACCOUNT_ID);
|
||||
expect(ids).not.toContain(BOB_ACCOUNT_ID);
|
||||
});
|
||||
|
||||
it('should not return another member workspace-shared account', async () => {
|
||||
const result = await service.findByUserWorkspaceId({
|
||||
userWorkspaceId: BOB_USER_WORKSPACE_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
const ids = result.map((account) => account.id);
|
||||
|
||||
expect(ids).toEqual([BOB_ACCOUNT_ID]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+109
-4
@@ -1,7 +1,13 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
type FindOptionsRelations,
|
||||
type FindOptionsWhere,
|
||||
Repository,
|
||||
} from 'typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service';
|
||||
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
|
||||
@@ -34,7 +40,7 @@ export class ConnectedAccountMetadataService {
|
||||
workspaceId: string;
|
||||
}): Promise<ConnectedAccountEntity[]> {
|
||||
return this.repository.find({
|
||||
where: { userWorkspaceId, workspaceId },
|
||||
where: this.getUserConditions({ userWorkspaceId, workspaceId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,6 +68,105 @@ export class ConnectedAccountMetadataService {
|
||||
});
|
||||
}
|
||||
|
||||
private getUserConditions({
|
||||
id,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
id?: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): FindOptionsWhere<ConnectedAccountEntity> {
|
||||
return {
|
||||
...(isDefined(id) ? { id } : {}),
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
private getWorkspaceSharedConditions({
|
||||
id,
|
||||
workspaceId,
|
||||
}: {
|
||||
id?: string;
|
||||
workspaceId: string;
|
||||
}): FindOptionsWhere<ConnectedAccountEntity> {
|
||||
return {
|
||||
...(isDefined(id) ? { id } : {}),
|
||||
workspaceId,
|
||||
visibility: 'workspace',
|
||||
};
|
||||
}
|
||||
|
||||
private getAccessibleConditions({
|
||||
id,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
id?: string;
|
||||
userWorkspaceId: string | undefined;
|
||||
workspaceId: string;
|
||||
}): FindOptionsWhere<ConnectedAccountEntity>[] {
|
||||
const workspaceSharedConditions = this.getWorkspaceSharedConditions({
|
||||
id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!isDefined(userWorkspaceId)) {
|
||||
return [workspaceSharedConditions];
|
||||
}
|
||||
|
||||
return [
|
||||
this.getUserConditions({ id, userWorkspaceId, workspaceId }),
|
||||
workspaceSharedConditions,
|
||||
];
|
||||
}
|
||||
|
||||
async findAccessibleConnectedAccounts({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
relations,
|
||||
}: {
|
||||
userWorkspaceId: string | undefined;
|
||||
workspaceId: string;
|
||||
relations?: FindOptionsRelations<ConnectedAccountEntity>;
|
||||
}): Promise<{
|
||||
userConnectedAccounts: ConnectedAccountEntity[];
|
||||
workspaceSharedConnectedAccounts: ConnectedAccountEntity[];
|
||||
}> {
|
||||
const accounts = await this.repository.find({
|
||||
where: this.getAccessibleConditions({ workspaceId, userWorkspaceId }),
|
||||
relations,
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
return {
|
||||
userConnectedAccounts: accounts.filter(
|
||||
(account) => account.userWorkspaceId === userWorkspaceId,
|
||||
),
|
||||
workspaceSharedConnectedAccounts: accounts.filter(
|
||||
(account) => account.userWorkspaceId !== userWorkspaceId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async findAccessibleConnectedAccountById({
|
||||
id,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
relations,
|
||||
}: {
|
||||
id: string;
|
||||
userWorkspaceId: string | undefined;
|
||||
workspaceId: string;
|
||||
relations?: FindOptionsRelations<ConnectedAccountEntity>;
|
||||
}): Promise<ConnectedAccountEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: this.getAccessibleConditions({ workspaceId, userWorkspaceId, id }),
|
||||
relations,
|
||||
});
|
||||
}
|
||||
|
||||
async verifyOwnership({
|
||||
id,
|
||||
userWorkspaceId,
|
||||
@@ -103,7 +208,7 @@ export class ConnectedAccountMetadataService {
|
||||
workspaceId: string;
|
||||
}): Promise<string[]> {
|
||||
const accounts = await this.repository.find({
|
||||
where: { userWorkspaceId, workspaceId },
|
||||
where: this.getUserConditions({ userWorkspaceId, workspaceId }),
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
@@ -116,7 +221,7 @@ export class ConnectedAccountMetadataService {
|
||||
workspaceId: string;
|
||||
}): Promise<string[]> {
|
||||
const accounts = await this.repository.find({
|
||||
where: { workspaceId, visibility: 'workspace' },
|
||||
where: this.getWorkspaceSharedConditions({ workspaceId }),
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ export class SendEmailResolver {
|
||||
files: input.files ?? [],
|
||||
inReplyTo: input.inReplyTo,
|
||||
},
|
||||
{ workspaceId: workspace.id },
|
||||
{ workspaceId: workspace.id, userWorkspaceId },
|
||||
{ attachmentsFileFolder: FileFolder.EmailAttachment },
|
||||
);
|
||||
|
||||
|
||||
+105
-1
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { FieldActorSource } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -12,22 +12,126 @@ import { fromWorkspaceEntityToFlat } from 'src/engine/core-modules/workspace/uti
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { STANDARD_ROLE } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-role.constant';
|
||||
import { type WorkflowRunWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
|
||||
import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
|
||||
import { type WorkflowExecutionContext } from 'src/modules/workflow/workflow-executor/types/workflow-execution-context.type';
|
||||
import { WorkflowRunWorkspaceService as WorkflowRunService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
|
||||
|
||||
@Injectable()
|
||||
// oxlint-disable-next-line twenty/inject-workspace-repository
|
||||
export class WorkflowExecutionContextService {
|
||||
private readonly logger = new Logger(WorkflowExecutionContextService.name);
|
||||
|
||||
constructor(
|
||||
private readonly workflowRunService: WorkflowRunService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly userRoleService: UserRoleService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly roleService: RoleService,
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
) {}
|
||||
|
||||
async getActingUserWorkspaceId(runInfo: {
|
||||
workflowRunId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<string | undefined> {
|
||||
const workflowRun = await this.workflowRunService.getWorkflowRunOrFail({
|
||||
workflowRunId: runInfo.workflowRunId,
|
||||
workspaceId: runInfo.workspaceId,
|
||||
});
|
||||
|
||||
const isManualRun =
|
||||
workflowRun.createdBy.source === FieldActorSource.MANUAL &&
|
||||
isDefined(workflowRun.createdBy.workspaceMemberId);
|
||||
|
||||
const workspaceMemberId = isManualRun
|
||||
? workflowRun.createdBy.workspaceMemberId
|
||||
: await this.getWorkflowCreatorWorkspaceMemberId({
|
||||
workflowId: workflowRun.workflowId,
|
||||
workspaceId: runInfo.workspaceId,
|
||||
});
|
||||
|
||||
if (!isDefined(workspaceMemberId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.resolveUserWorkspaceId({
|
||||
workspaceMemberId,
|
||||
workspaceId: runInfo.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
private async getWorkflowCreatorWorkspaceMemberId({
|
||||
workflowId,
|
||||
workspaceId,
|
||||
}: {
|
||||
workflowId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<string | null | undefined> {
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
const workflow =
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const workflowRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkflowWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workflow',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
return workflowRepository.findOne({ where: { id: workflowId } });
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
|
||||
return workflow?.createdBy?.workspaceMemberId;
|
||||
}
|
||||
|
||||
private async resolveUserWorkspaceId({
|
||||
workspaceMemberId,
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceMemberId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<string | undefined> {
|
||||
const workspaceMember = await this.userWorkspaceService
|
||||
.getWorkspaceMemberOrThrow({ workspaceMemberId, workspaceId })
|
||||
.catch((error) => {
|
||||
this.logger.warn(
|
||||
`Could not resolve workspace member ${workspaceMemberId} in workspace ${workspaceId}, falling back to workspace-shared accounts: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
if (!isDefined(workspaceMember)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const userWorkspace = await this.userWorkspaceService
|
||||
.getUserWorkspaceForUserOrThrow({
|
||||
userId: workspaceMember.userId,
|
||||
workspaceId,
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.warn(
|
||||
`Could not resolve user workspace for user ${workspaceMember.userId} in workspace ${workspaceId}, falling back to workspace-shared accounts: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return userWorkspace?.id;
|
||||
}
|
||||
|
||||
async getExecutionContext(runInfo: {
|
||||
workflowRunId: string;
|
||||
workspaceId: string;
|
||||
|
||||
+9
@@ -1,6 +1,7 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
|
||||
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
|
||||
import { HttpRequestWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action';
|
||||
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import {
|
||||
@@ -52,6 +53,14 @@ describe('HttpRequestWorkflowAction', () => {
|
||||
provide: WorkflowRunStepLogWorkspaceService,
|
||||
useValue: { setStepLog: mockSetStepLog },
|
||||
},
|
||||
{
|
||||
provide: WorkflowExecutionContextService,
|
||||
useValue: {
|
||||
getActingUserWorkspaceId: jest
|
||||
.fn()
|
||||
.mockResolvedValue('user-workspace-1'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
+14
-2
@@ -1,12 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
|
||||
import { HttpRequestWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action';
|
||||
import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module';
|
||||
|
||||
@Module({
|
||||
imports: [ToolModule, WorkflowRunModule],
|
||||
providers: [HttpRequestWorkflowAction],
|
||||
imports: [
|
||||
ToolModule,
|
||||
WorkflowRunModule,
|
||||
ApplicationModule,
|
||||
UserWorkspaceModule,
|
||||
UserRoleModule,
|
||||
RoleModule,
|
||||
],
|
||||
providers: [WorkflowExecutionContextService, HttpRequestWorkflowAction],
|
||||
exports: [HttpRequestWorkflowAction],
|
||||
})
|
||||
export class HttpRequestActionModule {}
|
||||
|
||||
+7
-1
@@ -9,6 +9,7 @@ import {
|
||||
WorkflowStepExecutorException,
|
||||
WorkflowStepExecutorExceptionCode,
|
||||
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
|
||||
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
|
||||
import { isWorkflowHttpRequestAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/guards/is-workflow-http-request-action.guard';
|
||||
import { type WorkflowHttpRequestActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type';
|
||||
import { buildHttpRequestStepLog } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/utils/build-http-request-step-log.util';
|
||||
@@ -21,8 +22,13 @@ export class HttpRequestWorkflowAction extends ToolBackedWorkflowAction<Workflow
|
||||
constructor(
|
||||
private readonly httpTool: HttpTool,
|
||||
workflowRunStepLogService: WorkflowRunStepLogWorkspaceService,
|
||||
workflowExecutionContextService: WorkflowExecutionContextService,
|
||||
) {
|
||||
super(HttpRequestWorkflowAction.name, workflowRunStepLogService);
|
||||
super(
|
||||
HttpRequestWorkflowAction.name,
|
||||
workflowRunStepLogService,
|
||||
workflowExecutionContextService,
|
||||
);
|
||||
}
|
||||
|
||||
protected getTool(): Tool {
|
||||
|
||||
+9
@@ -1,6 +1,7 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
|
||||
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
|
||||
import { DraftEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action';
|
||||
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import {
|
||||
@@ -58,6 +59,14 @@ describe('DraftEmailWorkflowAction', () => {
|
||||
provide: WorkflowRunStepLogWorkspaceService,
|
||||
useValue: { setStepLog: mockSetStepLog },
|
||||
},
|
||||
{
|
||||
provide: WorkflowExecutionContextService,
|
||||
useValue: {
|
||||
getActingUserWorkspaceId: jest
|
||||
.fn()
|
||||
.mockResolvedValue('user-workspace-1'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
+43
@@ -1,6 +1,7 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
|
||||
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
|
||||
import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action';
|
||||
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import {
|
||||
@@ -47,6 +48,7 @@ const buildSendEmailStep = (input: Record<string, unknown>): WorkflowAction =>
|
||||
describe('SendEmailWorkflowAction', () => {
|
||||
let action: SendEmailWorkflowAction;
|
||||
let mockSendEmailTool: jest.Mocked<Pick<SendEmailTool, 'execute'>>;
|
||||
let mockGetActingUserWorkspaceId: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
@@ -57,6 +59,9 @@ describe('SendEmailWorkflowAction', () => {
|
||||
error: undefined,
|
||||
}),
|
||||
};
|
||||
mockGetActingUserWorkspaceId = jest
|
||||
.fn()
|
||||
.mockResolvedValue('user-workspace-1');
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -66,6 +71,12 @@ describe('SendEmailWorkflowAction', () => {
|
||||
provide: WorkflowRunStepLogWorkspaceService,
|
||||
useValue: { setStepLog: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: WorkflowExecutionContextService,
|
||||
useValue: {
|
||||
getActingUserWorkspaceId: mockGetActingUserWorkspaceId,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -173,6 +184,38 @@ describe('SendEmailWorkflowAction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('acting user identity (regression #21177)', () => {
|
||||
it('passes the acting user workspace id to the email tool', async () => {
|
||||
await executeWithBody('hello');
|
||||
|
||||
expect(mockGetActingUserWorkspaceId).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-1',
|
||||
workflowRunId: 'run-1',
|
||||
});
|
||||
expect(mockSendEmailTool.execute).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
{
|
||||
workspaceId: 'workspace-1',
|
||||
userWorkspaceId: 'user-workspace-1',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('passes undefined user workspace id when no acting user resolves', async () => {
|
||||
mockGetActingUserWorkspaceId.mockResolvedValue(undefined);
|
||||
|
||||
await executeWithBody('hello');
|
||||
|
||||
expect(mockSendEmailTool.execute).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
{
|
||||
workspaceId: 'workspace-1',
|
||||
userWorkspaceId: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step type guard', () => {
|
||||
it('throws when the current step is not a send-email action', async () => {
|
||||
await expect(
|
||||
|
||||
+7
-1
@@ -6,6 +6,7 @@ import {
|
||||
WorkflowStepExecutorException,
|
||||
WorkflowStepExecutorExceptionCode,
|
||||
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
|
||||
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
|
||||
import { EmailWorkflowActionBase } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/email-workflow-action.base';
|
||||
import { isWorkflowDraftEmailAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-draft-email-action.guard';
|
||||
import { type EmailStepLogMode } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/build-email-step-log.util';
|
||||
@@ -17,8 +18,13 @@ export class DraftEmailWorkflowAction extends EmailWorkflowActionBase {
|
||||
constructor(
|
||||
private readonly draftEmailTool: DraftEmailTool,
|
||||
workflowRunStepLogService: WorkflowRunStepLogWorkspaceService,
|
||||
workflowExecutionContextService: WorkflowExecutionContextService,
|
||||
) {
|
||||
super(DraftEmailWorkflowAction.name, workflowRunStepLogService);
|
||||
super(
|
||||
DraftEmailWorkflowAction.name,
|
||||
workflowRunStepLogService,
|
||||
workflowExecutionContextService,
|
||||
);
|
||||
}
|
||||
|
||||
protected getTool(): Tool {
|
||||
|
||||
+18
-2
@@ -1,13 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
|
||||
import { DraftEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action';
|
||||
import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action';
|
||||
import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module';
|
||||
|
||||
@Module({
|
||||
imports: [ToolModule, WorkflowRunModule],
|
||||
providers: [SendEmailWorkflowAction, DraftEmailWorkflowAction],
|
||||
imports: [
|
||||
ToolModule,
|
||||
WorkflowRunModule,
|
||||
ApplicationModule,
|
||||
UserWorkspaceModule,
|
||||
UserRoleModule,
|
||||
RoleModule,
|
||||
],
|
||||
providers: [
|
||||
WorkflowExecutionContextService,
|
||||
SendEmailWorkflowAction,
|
||||
DraftEmailWorkflowAction,
|
||||
],
|
||||
exports: [SendEmailWorkflowAction, DraftEmailWorkflowAction],
|
||||
})
|
||||
export class MailSenderActionModule {}
|
||||
|
||||
+7
-1
@@ -6,6 +6,7 @@ import {
|
||||
WorkflowStepExecutorException,
|
||||
WorkflowStepExecutorExceptionCode,
|
||||
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
|
||||
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
|
||||
import { EmailWorkflowActionBase } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/email-workflow-action.base';
|
||||
import { isWorkflowSendEmailAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-send-email-action.guard';
|
||||
import { type EmailStepLogMode } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/build-email-step-log.util';
|
||||
@@ -17,8 +18,13 @@ export class SendEmailWorkflowAction extends EmailWorkflowActionBase {
|
||||
constructor(
|
||||
private readonly sendEmailTool: SendEmailTool,
|
||||
workflowRunStepLogService: WorkflowRunStepLogWorkspaceService,
|
||||
workflowExecutionContextService: WorkflowExecutionContextService,
|
||||
) {
|
||||
super(SendEmailWorkflowAction.name, workflowRunStepLogService);
|
||||
super(
|
||||
SendEmailWorkflowAction.name,
|
||||
workflowRunStepLogService,
|
||||
workflowExecutionContextService,
|
||||
);
|
||||
}
|
||||
|
||||
protected getTool(): Tool {
|
||||
|
||||
+8
@@ -7,6 +7,7 @@ import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.ty
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { type WorkflowAction as WorkflowActionContract } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
|
||||
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
|
||||
import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
|
||||
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
|
||||
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
|
||||
@@ -27,6 +28,7 @@ export abstract class ToolBackedWorkflowAction<
|
||||
protected constructor(
|
||||
loggerName: string,
|
||||
private readonly workflowRunStepLogService: WorkflowRunStepLogWorkspaceService,
|
||||
private readonly workflowExecutionContextService: WorkflowExecutionContextService,
|
||||
) {
|
||||
this.logger = new Logger(loggerName);
|
||||
}
|
||||
@@ -60,9 +62,15 @@ export abstract class ToolBackedWorkflowAction<
|
||||
const preprocessed = await this.preprocessInput(rawInput, context);
|
||||
const resolvedInput = resolveInput(preprocessed, context) as TInput;
|
||||
|
||||
const userWorkspaceId =
|
||||
await this.workflowExecutionContextService.getActingUserWorkspaceId(
|
||||
runInfo,
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const toolOutput = await this.getTool().execute(resolvedInput, {
|
||||
workspaceId: runInfo.workspaceId,
|
||||
userWorkspaceId,
|
||||
});
|
||||
const durationMs = Date.now() - startedAt;
|
||||
|
||||
|
||||
+33
@@ -118,4 +118,37 @@ describe('connectedAccountResolver (e2e)', () => {
|
||||
expect(response.body.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendEmail', () => {
|
||||
it("should reject sending from another member's connected account", async () => {
|
||||
const response = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation SendEmail($input: SendEmailInput!) {
|
||||
sendEmail(input: $input) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Should never be sent',
|
||||
body: '<p>Should never be sent</p>',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const result = response.body.data.sendEmail;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatchInlineSnapshot(
|
||||
`"Connected account 20202020-0cc8-4d60-a3a4-803245698908 does not belong to user workspace 20202020-1e7c-43d9-a5db-685b5069d816"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user