Refactor email composer (#21177)
# Introduction Gate what connected account can be ingested in case of ai mcp user workspace agnostic funnel to only the workspace shared connected account Added a quick win intregration tests on seeded connected accounts ( that wasn't covered but already protected fix impacts only the mcp ) Refactored the API slightly too ## Notice This mean there's a breaking change in the product behavior Whereas before a non user workspace related mcp interaction would might have fallback on any private user connected account it will now only search for workspace visible listed ones
This commit is contained in:
+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'],
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user