EncryptedString PlaintextString branded string types (#21001)

## Summary

closes https://github.com/twentyhq/core-team-issues/issues/2464

Introduces compile-time branded types to distinguish encrypted
ciphertext from plaintext strings, preventing mix-ups like the one fixed
in #20819 — but at the type level rather in addition to the one existing
at runtime.

### Branded string primitives

- Created `EncryptedString` and `PlaintextString` as hard nominal brands
using `z.string().brand(...)`, making them non-assignable to each other
or to raw `string`
- Created `isEncryptedString` type predicate to narrow `string` to
`EncryptedString` based on the `enc:v2:` envelope prefix
- Retyped `SecretEncryptionService`: `encryptVersioned` accepts
`PlaintextString`, `decryptVersioned` returns `PlaintextString`

### Entity typing

- Typed encrypted columns across entities:
`SigningKeyEntity.privateKey`,
`TwoFactorAuthenticationMethodEntity.secret`,
`ApplicationRegistrationVariableEntity.encryptedValue`,
`ApplicationVariableEntity.value`
- Parameterized JSONB types for connected account connection parameters
(`ImapSmtpCaldavParams<Pwd>`) with reusable aliases
`EncryptedImapSmtpCaldavParams` / `DecryptedImapSmtpCaldavParams`
- Typed DTOs (`CreateApplicationRegistrationVariableInput`,
`UpdateApplicationRegistrationVariablePayload`,
`UpdateApplicationVariableEntityInput`) with `PlaintextString`

### ApplicationVariable always-encrypt uniformization

- Retyped `ApplicationVariableEntity.value` to `EncryptedString | ''` —
all values are now encrypted regardless of `isSecret`
- Updated `ApplicationVariableEntityService` to always encrypt on write
and always decrypt on read
- Simplified `UpdateApplicationVariableActionHandlerService` by removing
conditional encrypt/decrypt-on-isSecret-toggle logic
- Added slow instance command (`2.9.0`) to backfill-encrypt existing
`isSecret=false` plaintext rows and tighten the `CHECK` constraint

### ConfigStorageService refactor

- Split `convertAndSecureValue` (which used `any`) into two well-typed
methods: `convertAndDecrypt` and `convertAndEncrypt`
- Introduced `isSensitiveStringValue` type predicate to narrow values
before encryption/decryption

### What's next
- Typeorm entity derivation to strictly type sitemap configuration as
code + handler logic for encryption rotation
- https://github.com/twentyhq/core-team-issues/issues/2465
This commit is contained in:
Paul Rastoin
2026-05-28 17:41:16 +02:00
committed by GitHub
parent 9b54200d8c
commit ebfaca5b3d
85 changed files with 1528 additions and 937 deletions
@@ -3,19 +3,22 @@ import { Injectable } from '@nestjs/common';
import { google } from 'googleapis';
import { isDefined } from 'twenty-shared/utils';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
ConnectedAccountRefreshAccessTokenException,
ConnectedAccountRefreshAccessTokenExceptionCode,
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
import { type ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
import { parseGoogleOAuthError } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/utils/parse-google-oauth-error.util';
import { type ConnectedAccountPlaintextTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
@Injectable()
export class GoogleAPIRefreshAccessTokenService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
async refreshTokens(refreshToken: string): Promise<ConnectedAccountTokens> {
async refreshTokens(
refreshToken: PlaintextString,
): Promise<ConnectedAccountPlaintextTokens> {
const oAuth2Client = new google.auth.OAuth2(
this.twentyConfigService.get('AUTH_GOOGLE_CLIENT_ID'),
this.twentyConfigService.get('AUTH_GOOGLE_CLIENT_SECRET'),
@@ -35,7 +38,7 @@ export class GoogleAPIRefreshAccessTokenService {
}
return {
accessToken: token,
accessToken: token as PlaintextString,
refreshToken,
};
} catch (error) {
@@ -2,19 +2,22 @@ import { Injectable } from '@nestjs/common';
import { ConfidentialClientApplication } from '@azure/msal-node';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
ConnectedAccountRefreshAccessTokenException,
ConnectedAccountRefreshAccessTokenExceptionCode,
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
import type { ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
import { parseMsalError } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/parse-msal-error.util';
import type { ConnectedAccountPlaintextTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
@Injectable()
export class MicrosoftAPIRefreshAccessTokenService {
constructor(private readonly config: TwentyConfigService) {}
async refreshTokens(refreshToken: string): Promise<ConnectedAccountTokens> {
async refreshTokens(
refreshToken: PlaintextString,
): Promise<ConnectedAccountPlaintextTokens> {
const msalClient = new ConfidentialClientApplication({
auth: {
clientId: this.config.get('AUTH_MICROSOFT_CLIENT_ID'),
@@ -38,7 +41,7 @@ export class MicrosoftAPIRefreshAccessTokenService {
}
return {
accessToken: response.accessToken,
accessToken: response.accessToken as PlaintextString,
refreshToken: this.extractRefreshTokenFromCache(msalClient),
};
} catch (error) {
@@ -52,7 +55,7 @@ export class MicrosoftAPIRefreshAccessTokenService {
private extractRefreshTokenFromCache(
msalClient: ConfidentialClientApplication,
): string {
): PlaintextString {
const tokenCache = JSON.parse(msalClient.getTokenCache().serialize());
const refreshTokenKey = Object.keys(tokenCache.RefreshToken)[0];
@@ -15,6 +15,7 @@ import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modu
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service';
import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service';
import { PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings';
import { ConnectedAccountRefreshTokensService } from './connected-account-refresh-tokens.service';
const FAKE_CIPHER_PREFIX = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}keyid:`;
@@ -32,9 +33,9 @@ describe('ConnectedAccountRefreshTokensService', () => {
const mockWorkspaceId = 'workspace-123';
const mockConnectedAccountId = 'account-456';
const mockAccessTokenPlaintext = 'valid-access-token';
const mockRefreshTokenPlaintext = 'valid-refresh-token';
const mockNewAccessTokenPlaintext = 'new-access-token';
const mockAccessTokenPlaintext = 'valid-access-token' as PlaintextString;
const mockRefreshTokenPlaintext = 'valid-refresh-token' as PlaintextString;
const mockNewAccessTokenPlaintext = 'new-access-token' as PlaintextString;
const mockEncryptedAccessToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockAccessTokenPlaintext})`;
const mockEncryptedRefreshToken = `${FAKE_CIPHER_PREFIX}CIPHER(${mockRefreshTokenPlaintext})`;
@@ -263,6 +264,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
const newPlaintextTokens = {
accessToken: mockNewAccessTokenPlaintext,
refreshToken: mockRefreshTokenPlaintext,
};
@@ -6,6 +6,8 @@ import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import {
ConnectedAccountRefreshAccessTokenException,
@@ -15,11 +17,24 @@ import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modu
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service';
import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service';
export type ConnectedAccountTokens = {
accessToken: string;
refreshToken: string | null;
// Tokens flowing through this service can be in two states depending on
// where they enter the pipeline. We model both shapes explicitly so the
// type system can prevent the #20819 class of bug (mixing encrypted and
// decrypted tokens in the same flow).
export type ConnectedAccountPlaintextTokens = {
accessToken: PlaintextString;
refreshToken: PlaintextString | null;
};
export type ConnectedAccountEncryptedTokens = {
accessToken: EncryptedString;
refreshToken: EncryptedString | null;
};
// Public return type of resolveTokens: always encrypted (either fresh from
// the database or freshly re-encrypted after a refresh round-trip).
export type ConnectedAccountTokens = ConnectedAccountEncryptedTokens;
const CONNECTED_ACCOUNT_ACCESS_TOKEN_EXPIRATION = 1000 * 60 * 60;
@Injectable()
@@ -91,7 +106,7 @@ export class ConnectedAccountRefreshTokensService {
private async performRefreshAndSave(
connectedAccount: ConnectedAccountEntity,
encryptedRefreshToken: string,
encryptedRefreshToken: EncryptedString,
workspaceId: string,
): Promise<ConnectedAccountTokens> {
const decryptedRefreshToken =
@@ -166,9 +181,9 @@ export class ConnectedAccountRefreshTokensService {
async refreshTokens(
connectedAccount: ConnectedAccountEntity,
refreshToken: string,
refreshToken: PlaintextString,
workspaceId: string,
): Promise<ConnectedAccountTokens> {
): Promise<ConnectedAccountPlaintextTokens> {
try {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
@@ -9,7 +9,12 @@ import {
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import {
type EncryptedImapSmtpCaldavParams,
type PlaintextImapSmtpCaldavParams,
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -117,12 +122,13 @@ describe('ImapSmtpCalDavAPIService', () => {
resetAndMarkAsCalendarEventListFetchPending: jest.fn(),
};
const encryptPassword = (password: string) => `enc:v2:${password}`;
const encryptPassword = (password: string): EncryptedString =>
`enc:v2:${password}` as EncryptedString;
const withEncryptedPasswords = (
params: ImapSmtpCaldavParams,
): ImapSmtpCaldavParams => {
const result: ImapSmtpCaldavParams = {};
params: PlaintextImapSmtpCaldavParams,
): EncryptedImapSmtpCaldavParams => {
const result: EncryptedImapSmtpCaldavParams = {};
for (const protocol of ['IMAP', 'SMTP', 'CALDAV'] as const) {
if (params[protocol]) {
@@ -141,7 +147,7 @@ describe('ImapSmtpCalDavAPIService', () => {
({
connectionParameters,
}: {
connectionParameters: ImapSmtpCaldavParams;
connectionParameters: PlaintextImapSmtpCaldavParams;
workspaceId: string;
}) => withEncryptedPasswords(connectionParameters),
),
@@ -240,16 +246,16 @@ describe('ImapSmtpCalDavAPIService', () => {
host: 'imap.example.com',
port: 993,
secure: true,
password: 'password',
password: 'password' as PlaintextString,
},
SMTP: {
host: 'smtp.example.com',
port: 587,
secure: true,
username: 'test@example.com',
password: 'password',
password: 'password' as PlaintextString,
},
} as ImapSmtpCaldavParams,
} as PlaintextImapSmtpCaldavParams,
};
it('should create new account with message channel when account does not exist and IMAP is configured', async () => {
@@ -338,9 +344,9 @@ describe('ImapSmtpCalDavAPIService', () => {
port: 443,
secure: true,
username: 'test@example.com',
password: 'password',
password: 'password' as PlaintextString,
},
} as ImapSmtpCaldavParams,
} as PlaintextImapSmtpCaldavParams,
connectedAccountId: 'existing-account-id',
};
@@ -467,9 +473,9 @@ describe('ImapSmtpCalDavAPIService', () => {
host: 'imap.example.com',
port: 993,
secure: true,
password: 'password',
password: 'password' as PlaintextString,
},
} as ImapSmtpCaldavParams,
} as PlaintextImapSmtpCaldavParams,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
@@ -503,9 +509,9 @@ describe('ImapSmtpCalDavAPIService', () => {
port: 443,
secure: true,
username: 'test@example.com',
password: 'password',
password: 'password' as PlaintextString,
},
} as ImapSmtpCaldavParams,
} as PlaintextImapSmtpCaldavParams,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
@@ -538,16 +544,16 @@ describe('ImapSmtpCalDavAPIService', () => {
host: 'imap.example.com',
port: 993,
secure: true,
password: 'password',
password: 'password' as PlaintextString,
},
SMTP: {
host: 'smtp.example.com',
port: 587,
secure: true,
username: 'test@example.com',
password: 'password',
password: 'password' as PlaintextString,
},
} as ImapSmtpCaldavParams,
} as PlaintextImapSmtpCaldavParams,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
@@ -580,23 +586,23 @@ describe('ImapSmtpCalDavAPIService', () => {
host: 'imap.example.com',
port: 993,
secure: true,
password: 'password',
password: 'password' as PlaintextString,
},
SMTP: {
host: 'smtp.example.com',
port: 587,
secure: true,
username: 'test@example.com',
password: 'password',
password: 'password' as PlaintextString,
},
CALDAV: {
host: 'caldav.example.com',
port: 443,
secure: true,
username: 'test@example.com',
password: 'password',
password: 'password' as PlaintextString,
},
} as ImapSmtpCaldavParams,
} as PlaintextImapSmtpCaldavParams,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
@@ -676,9 +682,9 @@ describe('ImapSmtpCalDavAPIService', () => {
port: 587,
secure: true,
username: 'test@example.com',
password: 'password',
password: 'password' as PlaintextString,
},
} as ImapSmtpCaldavParams,
} as PlaintextImapSmtpCaldavParams,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
@@ -13,7 +13,7 @@ import { v4 } from 'uuid';
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { type PlaintextImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@@ -65,7 +65,10 @@ export class ImapSmtpCalDavAPIService {
handle: string;
userWorkspaceId: string;
workspaceId: string;
connectionParameters: ImapSmtpCaldavParams;
// Caller (resolver) has already validated the input through
// `ImapSmtpCaldavService.validateAndTestConnectionParameters`, which
// produces fully plaintext passwords ready for re-encryption.
connectionParameters: PlaintextImapSmtpCaldavParams;
existingAccount?: ConnectedAccountEntity | null;
}): Promise<string> {
const { handle, workspaceId, userWorkspaceId } = input;
@@ -13,6 +13,7 @@ import { In } from 'typeorm';
import { type DiscoveredMessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { GmailGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/gmail/services/gmail-get-all-folders.service';
@@ -46,8 +47,8 @@ const createMockMessageChannel = (
id: 'account-456',
handle: 'test@gmail.com',
provider: overrides.provider ?? ConnectedAccountProvider.GOOGLE,
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
accessToken: 'mock-access-token' as EncryptedString,
refreshToken: 'mock-refresh-token' as EncryptedString,
connectionParameters: {},
workspaceId: 'workspace-123',
},
@@ -8,6 +8,7 @@ import {
import { type MessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
import { ImapGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service';
@@ -42,8 +43,8 @@ describe('ImapGetMessageListService', () => {
> = {
id: 'connected-account-id',
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
accessToken: 'access-token',
refreshToken: 'refresh-token',
accessToken: 'access-token' as EncryptedString,
refreshToken: 'refresh-token' as EncryptedString,
handle: 'test@example.com',
connectionParameters: {},
workspaceId: 'workspace-id',