[CONNECTED_ACCOUNT_BREAKING_CHANGE] Unify connected account permissions (#20732)

# Introduction
This PR is a followup of https://github.com/twentyhq/twenty/pull/20673

It aims to unify the authentication/permissions layer with all the
connectedAccount interactions across the application

## Deprecate
- findAll
- findById

## Email sync
An user can only sync the message of his own connected account

## Workflow email
- Related https://github.com/twentyhq/private-issues/issues/478
- Only reauthorize owned account
This commit is contained in:
Paul Rastoin
2026-05-20 13:36:58 +02:00
committed by GitHub
parent b454ad2aea
commit 3d49c17e34
21 changed files with 258 additions and 297 deletions
@@ -2,20 +2,19 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { In, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
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 {
ConnectedAccountException,
ConnectedAccountExceptionCode,
} from 'src/engine/metadata-modules/connected-account/connected-account.exception';
import { ConnectedAccountDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account.dto';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.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 { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
@@ -37,17 +36,13 @@ export class ConnectedAccountMetadataService {
private readonly appOAuthRevokeService: AppOAuthRevokeService,
) {}
async findAll(workspaceId: string): Promise<ConnectedAccountDTO[]> {
return this.repository.find({ where: { workspaceId } });
}
async findByUserWorkspaceId({
userWorkspaceId,
workspaceId,
}: {
userWorkspaceId: string;
workspaceId: string;
}): Promise<ConnectedAccountDTO[]> {
}): Promise<ConnectedAccountEntity[]> {
return this.repository.find({
where: { userWorkspaceId, workspaceId },
});
@@ -59,7 +54,7 @@ export class ConnectedAccountMetadataService {
}: {
id: string;
workspaceId: string;
}): Promise<ConnectedAccountDTO | null> {
}): Promise<ConnectedAccountEntity | null> {
return this.repository.findOne({ where: { id, workspaceId } });
}
@@ -77,18 +72,6 @@ export class ConnectedAccountMetadataService {
});
}
async findByIds({
ids,
workspaceId,
}: {
ids: string[];
workspaceId: string;
}): Promise<ConnectedAccountDTO[]> {
return this.repository.find({
where: { id: In(ids), workspaceId },
});
}
async verifyOwnership({
id,
userWorkspaceId,
@@ -141,7 +124,7 @@ export class ConnectedAccountMetadataService {
provider: string;
userWorkspaceId: string;
},
): Promise<ConnectedAccountDTO> {
): Promise<ConnectedAccountEntity> {
const entity = this.repository.create(data);
return this.repository.save(entity);
@@ -155,7 +138,7 @@ export class ConnectedAccountMetadataService {
id: string;
workspaceId: string;
data: Partial<ConnectedAccountEntity>;
}): Promise<ConnectedAccountDTO> {
}): Promise<ConnectedAccountEntity> {
await this.repository.update(
{ id, workspaceId },
data as Record<string, unknown>,
@@ -170,7 +153,7 @@ export class ConnectedAccountMetadataService {
}: {
id: string;
workspaceId: string;
}): Promise<ConnectedAccountDTO> {
}): Promise<ConnectedAccountEntity> {
const connectedAccount = await this.repository.findOneOrFail({
where: { id, workspaceId },
});
@@ -0,0 +1,23 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('ConnectedAccountHandleDTO')
export class ConnectedAccountHandleDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@IsNotEmpty()
@Field()
handle: string;
@IsString()
@IsNotEmpty()
@Field()
provider: string;
}
@@ -1,20 +1,18 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { ConnectedAccountDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account.dto';
import { ConnectedAccountPublicDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account-public.dto';
import { ConnectedAccountDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account.dto';
import { ConnectedAccountGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/connected-account/interceptors/connected-account-graphql-api-exception.interceptor';
import { buildPublicConnectedAccount } from 'src/engine/metadata-modules/connected-account/utils/build-public-connected-account.util';
@UseGuards(WorkspaceAuthGuard)
@UseInterceptors(ConnectedAccountGraphqlApiExceptionInterceptor)
@@ -30,30 +28,13 @@ export class ConnectedAccountResolver {
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<ConnectedAccountPublicDTO[]> {
return this.connectedAccountMetadataService.findByUserWorkspaceId({
userWorkspaceId,
workspaceId: workspace.id,
});
}
const accounts =
await this.connectedAccountMetadataService.findByUserWorkspaceId({
userWorkspaceId,
workspaceId: workspace.id,
});
@Query(() => ConnectedAccountPublicDTO, { nullable: true })
@UseGuards(NoPermissionGuard)
async connectedAccountById(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountPublicDTO | null> {
return this.connectedAccountMetadataService.findById({
id,
workspaceId: workspace.id,
});
}
@Query(() => [ConnectedAccountPublicDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
async connectedAccounts(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountPublicDTO[]> {
return this.connectedAccountMetadataService.findAll(workspace.id);
return accounts.map((account) => buildPublicConnectedAccount(account));
}
@Mutation(() => ConnectedAccountPublicDTO)
@@ -69,9 +50,11 @@ export class ConnectedAccountResolver {
workspaceId: workspace.id,
});
return this.connectedAccountMetadataService.delete({
const deleted = await this.connectedAccountMetadataService.delete({
id,
workspaceId: workspace.id,
});
return buildPublicConnectedAccount(deleted);
}
}
@@ -0,0 +1,24 @@
import { buildPublicConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/utils/build-public-connection-parameters.util';
import { type ConnectedAccountPublicDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account-public.dto';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
export function buildPublicConnectedAccount(
account: ConnectedAccountEntity,
): ConnectedAccountPublicDTO;
export function buildPublicConnectedAccount(
account: ConnectedAccountEntity | null,
): ConnectedAccountPublicDTO | null;
export function buildPublicConnectedAccount(
account: ConnectedAccountEntity | null,
): ConnectedAccountPublicDTO | null {
if (!account) {
return null;
}
return {
...account,
connectionParameters: buildPublicConnectionParameters(
account.connectionParameters,
),
};
}
@@ -6,34 +6,35 @@ import { isDefined } from 'twenty-shared/utils';
import { Not, Repository } from 'typeorm';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { buildPublicConnectedAccount } from 'src/engine/metadata-modules/connected-account/utils/build-public-connected-account.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { ConnectedAccountPublicDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account-public.dto';
import { CreateEmailGroupChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.input';
import { CreateEmailGroupChannelOutput } from 'src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.output';
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
import { UpdateMessageChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/update-message-channel.input';
import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-channel/interceptors/message-channel-graphql-api-exception.interceptor';
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
import {
MessageChannelException,
MessageChannelExceptionCode,
} from 'src/engine/metadata-modules/message-channel/message-channel.exception';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { MessagingProcessGroupEmailActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-group-email-actions.service';
import {
MessageChannelPendingGroupEmailsAction,
MessageChannelSyncStage,
MessageChannelType,
MessageFolderPendingSyncAction,
} from 'twenty-shared/types';
import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateEmailGroupChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.input';
import { CreateEmailGroupChannelOutput } from 'src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.output';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { ConnectedAccountPublicDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account-public.dto';
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
import { UpdateMessageChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/update-message-channel.input';
import {
MessageChannelException,
MessageChannelExceptionCode,
} from 'src/engine/metadata-modules/message-channel/message-channel.exception';
import { MessageChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-channel/interceptors/message-channel-graphql-api-exception.interceptor';
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { MessagingProcessGroupEmailActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-group-email-actions.service';
@UseGuards(WorkspaceAuthGuard)
@UseInterceptors(MessageChannelGraphqlApiExceptionInterceptor)
@@ -55,24 +56,23 @@ export class MessageChannelResolver {
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<ConnectedAccountPublicDTO | null> {
const connectedAccount =
await this.connectedAccountMetadataService.findById({
if (messageChannel.type === MessageChannelType.EMAIL_GROUP) {
const account = await this.connectedAccountMetadataService.findById({
id: messageChannel.connectedAccountId,
workspaceId: workspace.id,
});
if (!isDefined(connectedAccount)) {
return null;
return buildPublicConnectedAccount(account);
}
if (
messageChannel.type !== MessageChannelType.EMAIL_GROUP &&
connectedAccount.userWorkspaceId !== userWorkspaceId
) {
return null;
}
const account =
await this.connectedAccountMetadataService.findByIdAndUserWorkspaceId({
id: messageChannel.connectedAccountId,
userWorkspaceId,
workspaceId: workspace.id,
});
return connectedAccount;
return buildPublicConnectedAccount(account);
}
@Query(() => [MessageChannelDTO])