[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:
+14
@@ -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,
|
||||
|
||||
+13
-4
@@ -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 {
|
||||
|
||||
+3
-4
@@ -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()
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
+6
-6
@@ -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,
|
||||
|
||||
+88
-16
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user