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
@@ -1,7 +1,12 @@
import { isDefined } from 'twenty-shared/utils';
import { DataSource, QueryRunner } from 'typeorm';
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import {
type EncryptedImapSmtpCaldavParams,
type ImapSmtpCaldavParams,
type PlaintextImapSmtpCaldavParams,
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
@@ -16,6 +21,9 @@ const CHECK_CONSTRAINT_NAME =
type ConnectionParametersRow = {
id: string;
workspaceId: string;
// Pre-backfill rows can hold either plaintext or already-v2 passwords.
// Either form is structurally a `string` at the JSONB layer; the brand
// type is a phantom marker only.
connectionParameters: ImapSmtpCaldavParams | null;
};
@@ -67,7 +75,7 @@ export class EncryptConnectionParametersSlowInstanceCommand implements SlowInsta
continue;
}
const plaintextOnly: ImapSmtpCaldavParams = {};
const plaintextOnly: PlaintextImapSmtpCaldavParams = {};
for (const protocol of ACCOUNT_TYPES) {
const protocolParams = row.connectionParameters[protocol];
@@ -78,7 +86,13 @@ export class EncryptConnectionParametersSlowInstanceCommand implements SlowInsta
SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX,
)
) {
plaintextOnly[protocol] = protocolParams;
// Upstream filter guarantees this protocol's password is
// plaintext (no `enc:v2:` prefix); brand the leaf in-place so
// the encryption service can consume it.
plaintextOnly[protocol] = {
...protocolParams,
password: protocolParams.password as PlaintextString,
};
}
}
@@ -90,8 +104,12 @@ export class EncryptConnectionParametersSlowInstanceCommand implements SlowInsta
},
);
const merged: ImapSmtpCaldavParams = {
...row.connectionParameters,
// Pre-backfill row may already contain a mix of plaintext and
// already-encrypted protocols; we trust the entity-level brand on
// the post-merge result since each protocol is either freshly
// encrypted above or was already an `enc:v2:` envelope.
const merged: EncryptedImapSmtpCaldavParams = {
...(row.connectionParameters as EncryptedImapSmtpCaldavParams),
...encrypted,
};