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
@@ -16,7 +16,8 @@ import { type ConnectedAccountProvider } from 'twenty-shared/types';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { type EncryptedImapSmtpCaldavParams } 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 CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@@ -53,10 +54,10 @@ export class ConnectedAccountEntity extends WorkspaceRelatedEntity {
provider: ConnectedAccountProvider;
@Column({ type: 'varchar', nullable: true })
accessToken: string | null;
accessToken: EncryptedString | null;
@Column({ type: 'varchar', nullable: true })
refreshToken: string | null;
refreshToken: EncryptedString | null;
@Column({ type: 'timestamptz', nullable: true })
lastCredentialsRefreshedAt: Date | null;
@@ -71,7 +72,7 @@ export class ConnectedAccountEntity extends WorkspaceRelatedEntity {
scopes: string[] | null;
@Column({ type: 'jsonb', nullable: true })
connectionParameters: ImapSmtpCaldavParams | null;
connectionParameters: EncryptedImapSmtpCaldavParams | null;
@Column({ type: 'timestamptz', nullable: true })
lastSignedInAt: Date | null;
@@ -3,9 +3,13 @@ import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import {
type ConnectionParameters,
type ImapSmtpCaldavParams,
type EncryptedConnectionParameters,
type EncryptedImapSmtpCaldavParams,
type PlaintextConnectionParameters,
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 { SECRET_ENCRYPTION_ENVELOPE_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
import {
SecretEncryptionException,
@@ -28,9 +32,9 @@ export class ConnectedAccountTokenEncryptionService {
plaintext,
workspaceId,
}: {
plaintext: string;
plaintext: PlaintextString;
workspaceId: string;
}): string {
}): EncryptedString {
if (this.looksLikeCiphertext(plaintext)) {
throw new SecretEncryptionException(
'ConnectedAccountTokenEncryptionService.encrypt received an already-encrypted envelope. This indicates a double-encryption bug — the caller is encrypting ciphertext.',
@@ -47,9 +51,9 @@ export class ConnectedAccountTokenEncryptionService {
plaintext,
workspaceId,
}: {
plaintext: string | null;
plaintext: PlaintextString | null;
workspaceId: string;
}): string | null {
}): EncryptedString | null {
if (!isDefined(plaintext)) {
return null;
}
@@ -61,9 +65,9 @@ export class ConnectedAccountTokenEncryptionService {
ciphertext,
workspaceId,
}: {
ciphertext: string;
ciphertext: EncryptedString;
workspaceId: string;
}): string {
}): PlaintextString {
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.',
@@ -80,9 +84,9 @@ export class ConnectedAccountTokenEncryptionService {
ciphertext,
workspaceId,
}: {
ciphertext: string | null;
ciphertext: EncryptedString | null;
workspaceId: string;
}): string | null {
}): PlaintextString | null {
if (!isDefined(ciphertext)) {
return null;
}
@@ -95,12 +99,12 @@ export class ConnectedAccountTokenEncryptionService {
refreshToken,
workspaceId,
}: {
accessToken: string;
refreshToken: string | null;
accessToken: PlaintextString;
refreshToken: PlaintextString | null;
workspaceId: string;
}): {
encryptedAccessToken: string;
encryptedRefreshToken: string | null;
encryptedAccessToken: EncryptedString;
encryptedRefreshToken: EncryptedString | null;
} {
return {
encryptedAccessToken: this.encrypt({
@@ -122,10 +126,10 @@ export class ConnectedAccountTokenEncryptionService {
connectionParameters,
workspaceId,
}: {
connectionParameters: ImapSmtpCaldavParams;
connectionParameters: PlaintextImapSmtpCaldavParams;
workspaceId: string;
}): ImapSmtpCaldavParams {
const result: ImapSmtpCaldavParams = {};
}): EncryptedImapSmtpCaldavParams {
const result: EncryptedImapSmtpCaldavParams = {};
for (const protocol of ACCOUNT_TYPES) {
const params = connectionParameters[protocol];
@@ -147,10 +151,10 @@ export class ConnectedAccountTokenEncryptionService {
connectionParameters,
workspaceId,
}: {
connectionParameters: ImapSmtpCaldavParams;
connectionParameters: EncryptedImapSmtpCaldavParams;
workspaceId: string;
}): ImapSmtpCaldavParams {
const result: ImapSmtpCaldavParams = {};
}): PlaintextImapSmtpCaldavParams {
const result: PlaintextImapSmtpCaldavParams = {};
for (const protocol of ACCOUNT_TYPES) {
const params = connectionParameters[protocol];
@@ -172,20 +176,31 @@ export class ConnectedAccountTokenEncryptionService {
protocolParams,
workspaceId,
}: {
protocolParams: ConnectionParameters;
protocolParams: EncryptedConnectionParameters;
workspaceId: string;
}): ConnectionParameters {
}): PlaintextConnectionParameters {
const isEncrypted = protocolParams.password.startsWith(
SECRET_ENCRYPTION_ENVELOPE_PREFIX,
);
// TODO: Remove after 2-5 slow instance command has been run everywhere
// TODO: Remove in follow-up PR once all legacy encryption fallbacks are dropped.
// TODO: Remove after 2-5 slow instance command has been run everywhere.
// During the rollout window protocolParams.password may be a legacy
// unencrypted plaintext value living in the same column. We trust the
// entity-level brand at the type layer (column is EncryptedString) but
// still re-validate at runtime to handle the un-backfilled tail; the
// assert above splits the two.
if (!isEncrypted) {
this.logger.warn(
'Protocol password is not encrypted. Expected during the rollout window until the slow instance command finishes backfilling.',
);
return protocolParams;
const rawPassword: string = protocolParams.password;
return {
...protocolParams,
password: rawPassword as PlaintextString,
};
}
return {
@@ -1,3 +1,4 @@
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
import { stripSecretFromApplicationVariables } from 'src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables';
@@ -6,7 +7,7 @@ const makeFlatVariable = (
): FlatApplicationVariable => ({
id: '1',
key: 'KEY',
value: 'value',
value: 'value' as EncryptedString,
description: '',
isSecret: false,
applicationId: 'app-1',
@@ -25,8 +26,15 @@ describe('stripSecretFromApplicationVariables', () => {
it('should include non-secret variables', () => {
const variables = [
makeFlatVariable({ key: 'PUBLIC_URL', value: 'https://example.com' }),
makeFlatVariable({ id: '2', key: 'DEBUG', value: 'true' }),
makeFlatVariable({
key: 'PUBLIC_URL',
value: 'https://example.com' as EncryptedString,
}),
makeFlatVariable({
id: '2',
key: 'DEBUG',
value: 'true' as EncryptedString,
}),
];
expect(stripSecretFromApplicationVariables(variables)).toEqual({
@@ -37,14 +45,21 @@ describe('stripSecretFromApplicationVariables', () => {
it('should exclude secret variables', () => {
const variables = [
makeFlatVariable({ key: 'PUBLIC_URL', value: 'https://example.com' }),
makeFlatVariable({
key: 'PUBLIC_URL',
value: 'https://example.com' as EncryptedString,
}),
makeFlatVariable({
id: '2',
key: 'API_SECRET',
value: 'encrypted_secret',
value: 'encrypted_secret' as EncryptedString,
isSecret: true,
}),
makeFlatVariable({ id: '3', key: 'DEBUG', value: 'true' }),
makeFlatVariable({
id: '3',
key: 'DEBUG',
value: 'true' as EncryptedString,
}),
];
const result = stripSecretFromApplicationVariables(variables);
@@ -60,12 +75,12 @@ describe('stripSecretFromApplicationVariables', () => {
const variables = [
makeFlatVariable({
key: 'NULL_VALUE',
value: null as unknown as string,
value: null as unknown as EncryptedString | '',
}),
makeFlatVariable({
id: '2',
key: 'UNDEFINED_VALUE',
value: undefined as unknown as string,
value: undefined as unknown as EncryptedString | '',
}),
];
@@ -79,7 +94,7 @@ describe('stripSecretFromApplicationVariables', () => {
const variables = [
makeFlatVariable({
key: 'NUMBER_VALUE',
value: 123 as unknown as string,
value: 123 as unknown as EncryptedString | '',
}),
];
@@ -90,11 +105,15 @@ describe('stripSecretFromApplicationVariables', () => {
it('should return empty object when all variables are secret', () => {
const variables = [
makeFlatVariable({ key: 'SECRET_1', value: 'val1', isSecret: true }),
makeFlatVariable({
key: 'SECRET_1',
value: 'val1' as EncryptedString,
isSecret: true,
}),
makeFlatVariable({
id: '2',
key: 'SECRET_2',
value: 'val2',
value: 'val2' as EncryptedString,
isSecret: true,
}),
];