Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d63167c422 | ||
|
|
57f79c60c6 | ||
|
|
b31da5d315 | ||
|
|
deb01ec823 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.9.1",
|
||||
"version": "2.9.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "2.9.1",
|
||||
"version": "2.9.0",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "2.9.1",
|
||||
"version": "2.9.0",
|
||||
"sideEffects": false,
|
||||
"bin": {
|
||||
"twenty": "dist/cli.cjs"
|
||||
|
||||
@@ -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 { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
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,8 +26,7 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
|
||||
imports: [
|
||||
MessagingImportManagerModule,
|
||||
MessagingSendManagerModule,
|
||||
TypeOrmModule.forFeature([FileEntity]),
|
||||
ConnectedAccountMetadataModule,
|
||||
TypeOrmModule.forFeature([FileEntity, ConnectedAccountEntity]),
|
||||
ApplicationModule,
|
||||
FeatureFlagModule,
|
||||
FileModule,
|
||||
|
||||
-287
@@ -1,287 +0,0 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+59
-71
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { toPlainText } from '@react-email/render';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
FileFolder,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { In, LessThanOrEqual } from 'typeorm';
|
||||
import { In, LessThanOrEqual, type Repository } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
@@ -23,7 +24,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 { 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 { 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';
|
||||
@@ -44,21 +45,17 @@ export class EmailComposerService {
|
||||
|
||||
constructor(
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
|
||||
@InjectRepository(ConnectedAccountEntity)
|
||||
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
|
||||
@InjectWorkspaceScopedRepository(FileEntity)
|
||||
private readonly fileRepository: WorkspaceScopedRepository<FileEntity>,
|
||||
private readonly fileService: FileService,
|
||||
) {}
|
||||
|
||||
private async getConnectedAccount({
|
||||
connectedAccountId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
connectedAccountId: string;
|
||||
workspaceId: string;
|
||||
userWorkspaceId: string | undefined;
|
||||
}) {
|
||||
private async getConnectedAccount(
|
||||
connectedAccountId: string,
|
||||
workspaceId: string,
|
||||
) {
|
||||
if (!isValidUuid(connectedAccountId)) {
|
||||
throw new EmailToolException(
|
||||
`Connected Account ID is not a valid UUID`,
|
||||
@@ -66,63 +63,54 @@ export class EmailComposerService {
|
||||
);
|
||||
}
|
||||
|
||||
const connectedAccount =
|
||||
await this.connectedAccountMetadataService.findAccessibleConnectedAccountById(
|
||||
{
|
||||
id: connectedAccountId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const connectedAccount = await this.connectedAccountRepository.findOne({
|
||||
where: { id: connectedAccountId, workspaceId },
|
||||
relations: {
|
||||
messageChannels: {
|
||||
messageFolders: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (!isDefined(connectedAccount)) {
|
||||
throw new EmailToolException(
|
||||
`Connected Account '${connectedAccountId}' not found`,
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (!isDefined(connectedAccount)) {
|
||||
throw new EmailToolException(
|
||||
`Connected Account '${connectedAccountId}' not found`,
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return connectedAccount;
|
||||
return connectedAccount;
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
}
|
||||
|
||||
private async getDefaultConnectedAccountOrThrow({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
userWorkspaceId: string | undefined;
|
||||
}) {
|
||||
const { userConnectedAccounts, workspaceSharedConnectedAccounts } =
|
||||
await this.connectedAccountMetadataService.findAccessibleConnectedAccounts(
|
||||
{
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
relations: {
|
||||
messageChannels: {
|
||||
messageFolders: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
private async getOrThrowFirstConnectedAccountId(
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const authContext = buildSystemAuthContext(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];
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
const allAccounts = await this.connectedAccountRepository.find({
|
||||
where: { workspaceId },
|
||||
});
|
||||
|
||||
if (!isDefined(connectedAccount)) {
|
||||
throw new EmailToolException(
|
||||
'No connected accounts found for this workspace',
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (!allAccounts || allAccounts.length === 0) {
|
||||
throw new EmailToolException(
|
||||
'No connected accounts found for this workspace',
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return connectedAccount;
|
||||
return allAccounts[0].id;
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeRecipients(parameters: ComposeEmailParams): {
|
||||
@@ -326,8 +314,9 @@ export class EmailComposerService {
|
||||
context: ToolExecutionContext,
|
||||
options: { attachmentsFileFolder: FileFolder },
|
||||
): Promise<EmailComposerResult> {
|
||||
const { workspaceId, userWorkspaceId } = context;
|
||||
const { subject, body, files, inReplyTo, connectedAccountId } = parameters;
|
||||
const { workspaceId } = context;
|
||||
const { subject, body, files, inReplyTo } = parameters;
|
||||
let { connectedAccountId } = parameters;
|
||||
|
||||
let recipients: { to: string[]; cc: string[]; bcc: string[] };
|
||||
|
||||
@@ -363,16 +352,15 @@ export class EmailComposerService {
|
||||
|
||||
const toRecipientsDisplay = recipients.to.join(', ');
|
||||
|
||||
const connectedAccount = isNonEmptyString(connectedAccountId)
|
||||
? await this.getConnectedAccount({
|
||||
connectedAccountId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
})
|
||||
: await this.getDefaultConnectedAccountOrThrow({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
});
|
||||
if (!connectedAccountId) {
|
||||
connectedAccountId =
|
||||
await this.getOrThrowFirstConnectedAccountId(workspaceId);
|
||||
}
|
||||
|
||||
const connectedAccount = await this.getConnectedAccount(
|
||||
connectedAccountId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const messageChannel = connectedAccount.messageChannels.find(
|
||||
(channel) => channel.handle === connectedAccount.handle,
|
||||
@@ -387,14 +375,14 @@ export class EmailComposerService {
|
||||
!isDefined(connectedAccount.connectionParameters?.SMTP)
|
||||
) {
|
||||
throw new EmailToolException(
|
||||
`SMTP is not configured for connected account '${connectedAccount.id}'`,
|
||||
`SMTP is not configured for connected account '${connectedAccountId}'`,
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSmtpOnlyAccount && !isDefined(messageChannel)) {
|
||||
throw new EmailToolException(
|
||||
`No message channel found for connected account '${connectedAccount.id}'`,
|
||||
`No message channel found for connected account '${connectedAccountId}'`,
|
||||
EmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
-214
@@ -1,214 +0,0 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+4
-109
@@ -1,13 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
type FindOptionsRelations,
|
||||
type FindOptionsWhere,
|
||||
Repository,
|
||||
} from 'typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from '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';
|
||||
@@ -40,7 +34,7 @@ export class ConnectedAccountMetadataService {
|
||||
workspaceId: string;
|
||||
}): Promise<ConnectedAccountEntity[]> {
|
||||
return this.repository.find({
|
||||
where: this.getUserConditions({ userWorkspaceId, workspaceId }),
|
||||
where: { userWorkspaceId, workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,105 +62,6 @@ 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,
|
||||
@@ -208,7 +103,7 @@ export class ConnectedAccountMetadataService {
|
||||
workspaceId: string;
|
||||
}): Promise<string[]> {
|
||||
const accounts = await this.repository.find({
|
||||
where: this.getUserConditions({ userWorkspaceId, workspaceId }),
|
||||
where: { userWorkspaceId, workspaceId },
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
@@ -221,7 +116,7 @@ export class ConnectedAccountMetadataService {
|
||||
workspaceId: string;
|
||||
}): Promise<string[]> {
|
||||
const accounts = await this.repository.find({
|
||||
where: this.getWorkspaceSharedConditions({ workspaceId }),
|
||||
where: { workspaceId, visibility: 'workspace' },
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
|
||||
+12
-1
@@ -279,11 +279,22 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
|
||||
const hasPermissionFromSettingPermissions = isDefined(
|
||||
rolePermissionFlags.find(
|
||||
(rolePermissionFlag) =>
|
||||
rolePermissionFlag.permissionFlag.universalIdentifier ===
|
||||
this.getRolePermissionFlagUniversalIdentifier(rolePermissionFlag) ===
|
||||
permissionFlagUniversalIdentifier,
|
||||
),
|
||||
);
|
||||
|
||||
return hasPermissionFromRole || hasPermissionFromSettingPermissions;
|
||||
}
|
||||
|
||||
private getRolePermissionFlagUniversalIdentifier(
|
||||
rolePermissionFlag: RolePermissionFlagEntity,
|
||||
): string {
|
||||
// The `permissionFlag` relation is stripped during upgrades until the 2.6.0
|
||||
// cursor (@WasIntroducedInUpgrade), so fall back to the legacy `flag` column.
|
||||
return (
|
||||
rolePermissionFlag.permissionFlag?.universalIdentifier ??
|
||||
SystemPermissionFlag[rolePermissionFlag.flag]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -12,6 +12,7 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
|
||||
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
|
||||
@@ -64,6 +65,10 @@ export class ViewFilterEntity
|
||||
@Column({ nullable: true, type: 'text', default: null })
|
||||
subFieldName: string | null;
|
||||
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName:
|
||||
'2.6.0_AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand_1798000005000',
|
||||
})
|
||||
@Column({ nullable: true, type: 'uuid', default: null })
|
||||
relationTargetFieldMetadataId: string | null;
|
||||
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ export class SendEmailResolver {
|
||||
files: input.files ?? [],
|
||||
inReplyTo: input.inReplyTo,
|
||||
},
|
||||
{ workspaceId: workspace.id, userWorkspaceId },
|
||||
{ workspaceId: workspace.id },
|
||||
{ attachmentsFileFolder: FileFolder.EmailAttachment },
|
||||
);
|
||||
|
||||
|
||||
-33
@@ -118,37 +118,4 @@ 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