[CONNECTED_ACCOUNT_BREAKING_CHANGE] Encrypt ConnectedAccount connectionParameters (#20673)

# Introduction
Prevent any cross user `connectedAccount` `connectionParamaters` leak
Also encrypt in db all `connectionParameters` password
Never return any password through `DTO` anymore
The settings now allow update mutation without providing the password in
edition mode

Verified all `connectionParameters.password` interaction

## Integration tests
- Added more coverage for both failing and successful paths
- Introduced a new env var that allow bypass the provider connection
test

## Legacy connected Account decryption support
Stop allowing non encrypted decryption on `accessToken` and
`refreshToken`, only allow legacy decryption on refactored
`connectionParameters`

## Upsert ownership
Completely got rid of the connected workspace schema context which is
legacy
Also now a user can only upsert a connected account for him only..

## New UI
<img width="1770" height="1852" alt="image"
src="https://github.com/user-attachments/assets/55c1dc89-42ff-4084-95e2-cc5f9e23753b"
/>
If in edition the password is by default disabled
It needs to be selected as being edited to be enabled

## Next
- Refactor tool permissions flag not to include connected accounts
- Remove the legacy connected standard object
- Refactor and improve connected account resolver auth
This commit is contained in:
Paul Rastoin
2026-05-19 14:56:44 +02:00
committed by GitHub
parent 72c0c36db5
commit 57f13c9b92
61 changed files with 2263 additions and 1340 deletions
@@ -63,6 +63,20 @@ export class ConnectedAccountMetadataService {
return this.repository.findOne({ where: { id, workspaceId } });
}
async findByIdAndUserWorkspaceId({
id,
userWorkspaceId,
workspaceId,
}: {
id: string;
userWorkspaceId: string;
workspaceId: string;
}): Promise<ConnectedAccountEntity | null> {
return this.repository.findOne({
where: { id, userWorkspaceId, workspaceId },
});
}
async findByIds({
ids,
workspaceId,
@@ -2,13 +2,22 @@ import { Field, ObjectType, OmitType } from '@nestjs/graphql';
import { IsOptional } from 'class-validator';
import { ConnectionParametersDTO } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
import { ConnectedAccountDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account.dto';
@ObjectType('PublicConnectionParametersOutput')
class PublicConnectionParametersDTO extends OmitType(ConnectionParametersDTO, [
'password',
] as const) {}
class PublicConnectionParametersDTO {
@Field(() => String)
host: string;
@Field(() => Number)
port: number;
@Field(() => String, { nullable: true })
username?: string;
@Field(() => Boolean, { nullable: true })
secure?: boolean;
}
@ObjectType('PublicImapSmtpCaldavConnectionParameters')
class PublicImapSmtpCaldavConnectionParametersDTO {
@@ -10,7 +10,7 @@ import {
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ImapSmtpCaldavConnectionParametersDTO } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
@ObjectType('ConnectedAccountDTO')
export class ConnectedAccountDTO {
@@ -55,9 +55,8 @@ export class ConnectedAccountDTO {
@Field(() => [String], { nullable: true })
scopes: string[] | null;
@IsOptional()
@Field(() => ImapSmtpCaldavConnectionParametersDTO, { nullable: true })
connectionParameters: ImapSmtpCaldavConnectionParametersDTO | null;
@HideField()
connectionParameters: ImapSmtpCaldavParams | null;
@IsDateString()
@IsOptional()
@@ -34,6 +34,14 @@ export type ConnectedAccountVisibility = 'user' | 'workspace';
'CHK_connectedAccount_refreshToken_encrypted',
`"refreshToken" IS NULL OR "refreshToken" LIKE 'enc:v2:%'`,
)
@Check(
'CHK_connectedAccount_connectionParameters_encrypted',
`"connectionParameters" IS NULL OR (` +
`(("connectionParameters"->'IMAP'->>'password') IS NULL OR ("connectionParameters"->'IMAP'->>'password') LIKE 'enc:v2:%') ` +
`AND (("connectionParameters"->'SMTP'->>'password') IS NULL OR ("connectionParameters"->'SMTP'->>'password') LIKE 'enc:v2:%') ` +
`AND (("connectionParameters"->'CALDAV'->>'password') IS NULL OR ("connectionParameters"->'CALDAV'->>'password') LIKE 'enc:v2:%')` +
`)`,
)
export class ConnectedAccountEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -24,12 +24,12 @@ export class ConnectedAccountResolver {
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
) {}
@Query(() => [ConnectedAccountDTO])
@Query(() => [ConnectedAccountPublicDTO])
@UseGuards(NoPermissionGuard)
async myConnectedAccounts(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<ConnectedAccountDTO[]> {
): Promise<ConnectedAccountPublicDTO[]> {
return this.connectedAccountMetadataService.findByUserWorkspaceId({
userWorkspaceId,
workspaceId: workspace.id,
@@ -48,21 +48,21 @@ export class ConnectedAccountResolver {
});
}
@Query(() => [ConnectedAccountDTO])
@Query(() => [ConnectedAccountPublicDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
async connectedAccounts(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO[]> {
): Promise<ConnectedAccountPublicDTO[]> {
return this.connectedAccountMetadataService.findAll(workspace.id);
}
@Mutation(() => ConnectedAccountDTO)
@Mutation(() => ConnectedAccountPublicDTO)
@UseGuards(NoPermissionGuard)
async deleteConnectedAccount(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<ConnectedAccountDTO> {
): Promise<ConnectedAccountPublicDTO> {
await this.connectedAccountMetadataService.verifyOwnership({
id,
userWorkspaceId,
@@ -2,12 +2,17 @@ import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import {
type ConnectionParameters,
type ImapSmtpCaldavParams,
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { SECRET_ENCRYPTION_ENVELOPE_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
import {
SecretEncryptionException,
SecretEncryptionExceptionCode,
} from 'src/engine/core-modules/secret-encryption/exceptions/secret-encryption.exception';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { parseSecretEncryptionEnvelopeOrThrow } from 'src/engine/core-modules/secret-encryption/utils/parse-secret-encryption-envelope-or-throw.util';
import { ACCOUNT_TYPES } from 'twenty-shared/constants';
@Injectable()
export class ConnectedAccountTokenEncryptionService {
@@ -52,9 +57,6 @@ export class ConnectedAccountTokenEncryptionService {
return this.encrypt({ plaintext, workspaceId });
}
// v2.4.0 rollout-window tolerance: rows written before the encryption
// backfill ran may still be plaintext. Returning them as-is lets the slow
// command finish; once it has run everywhere this branch can throw.
decrypt({
ciphertext,
workspaceId,
@@ -62,14 +64,11 @@ export class ConnectedAccountTokenEncryptionService {
ciphertext: string;
workspaceId: string;
}): string {
const parsed = parseSecretEncryptionEnvelopeOrThrow({ value: ciphertext });
if (!isDefined(parsed.version)) {
this.logger.warn(
'Decrypted a legacy plaintext token. Expected during the rollout window until the slow instance command finishes backfilling.',
if (!ciphertext.startsWith(SECRET_ENCRYPTION_ENVELOPE_PREFIX)) {
throw new SecretEncryptionException(
'Received a plaintext value where ciphertext was expected. The encryption backfill migration may not have run.',
SecretEncryptionExceptionCode.MALFORMED_ENVELOPE,
);
return ciphertext;
}
return this.secretEncryptionService.decryptVersioned(ciphertext, {
@@ -116,12 +115,85 @@ export class ConnectedAccountTokenEncryptionService {
}
private looksLikeCiphertext(value: string): boolean {
try {
const parsed = parseSecretEncryptionEnvelopeOrThrow({ value });
return value.startsWith(SECRET_ENCRYPTION_ENVELOPE_PREFIX);
}
return parsed.version === 2;
} catch {
return false;
encryptConnectionParameters({
connectionParameters,
workspaceId,
}: {
connectionParameters: ImapSmtpCaldavParams;
workspaceId: string;
}): ImapSmtpCaldavParams {
const result: ImapSmtpCaldavParams = {};
for (const protocol of ACCOUNT_TYPES) {
const params = connectionParameters[protocol];
if (!isDefined(params)) {
continue;
}
result[protocol] = {
...params,
password: this.encrypt({ plaintext: params.password, workspaceId }),
};
}
return result;
}
decryptConnectionParameters({
connectionParameters,
workspaceId,
}: {
connectionParameters: ImapSmtpCaldavParams;
workspaceId: string;
}): ImapSmtpCaldavParams {
const result: ImapSmtpCaldavParams = {};
for (const protocol of ACCOUNT_TYPES) {
const params = connectionParameters[protocol];
if (!isDefined(params)) {
continue;
}
result[protocol] = this.decryptProtocolPassword({
protocolParams: params,
workspaceId,
});
}
return result;
}
decryptProtocolPassword({
protocolParams,
workspaceId,
}: {
protocolParams: ConnectionParameters;
workspaceId: string;
}): ConnectionParameters {
const isEncrypted = protocolParams.password.startsWith(
SECRET_ENCRYPTION_ENVELOPE_PREFIX,
);
// TODO: Remove after 2-5 slow instance command has been run everywhere
if (!isEncrypted) {
this.logger.warn(
'Protocol password is not encrypted. Expected during the rollout window until the slow instance command finishes backfilling.',
);
return protocolParams;
}
return {
...protocolParams,
password: this.decrypt({
ciphertext: protocolParams.password,
workspaceId,
}),
};
}
}