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
@@ -306,9 +306,11 @@ export class LogicFunctionExecutorService {
// use the instance-scoped versioned envelope (no workspaceId in the HKDF
// info).
for (const variable of serverVariables) {
envMap[variable.key] = this.secretEncryptionService.decryptVersioned(
variable.encryptedValue,
);
if (variable.encryptedValue !== '') {
envMap[variable.key] = this.secretEncryptionService.decryptVersioned(
variable.encryptedValue,
);
}
}
return envMap;
@@ -1,6 +1,7 @@
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var';
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
describe('buildEnvVar', () => {
const workspaceA = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
@@ -27,12 +28,13 @@ describe('buildEnvVar', () => {
expect(result).toEqual({});
});
it('should decrypt secret variables with the row workspaceId bound to HKDF', () => {
it('should decrypt all encrypted variables regardless of isSecret', () => {
const flatVariables: FlatApplicationVariable[] = [
{
id: '1',
key: 'PUBLIC_URL',
value: 'https://example.com',
value:
`enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString,
description: 'Public URL',
isSecret: false,
applicationId: 'app-1',
@@ -45,7 +47,7 @@ describe('buildEnvVar', () => {
{
id: '2',
key: 'API_SECRET',
value: `enc:v2:deadbeef:secret-123|${workspaceA}`,
value: `enc:v2:deadbeef:secret-123|${workspaceA}` as EncryptedString,
description: 'API secret',
isSecret: true,
applicationId: 'app-1',
@@ -58,7 +60,7 @@ describe('buildEnvVar', () => {
{
id: '3',
key: 'DEBUG',
value: 'true',
value: `enc:v2:deadbeef:true|${workspaceA}` as EncryptedString,
description: 'Debug flag',
isSecret: false,
applicationId: 'app-1',
@@ -78,11 +80,7 @@ describe('buildEnvVar', () => {
DEBUG: 'true',
});
expect(mockSecretEncryptionService.decryptVersioned).toHaveBeenCalledTimes(
1,
);
expect(mockSecretEncryptionService.decryptVersioned).toHaveBeenCalledWith(
`enc:v2:deadbeef:secret-123|${workspaceA}`,
{ workspaceId: workspaceA },
3,
);
});
@@ -91,7 +89,7 @@ describe('buildEnvVar', () => {
{
id: '1',
key: 'A_SECRET',
value: `enc:v2:deadbeef:value-a|${workspaceA}`,
value: `enc:v2:deadbeef:value-a|${workspaceA}` as EncryptedString,
description: '',
isSecret: true,
applicationId: 'app-1',
@@ -104,7 +102,7 @@ describe('buildEnvVar', () => {
{
id: '2',
key: 'B_SECRET',
value: `enc:v2:deadbeef:value-b|${workspaceB}`,
value: `enc:v2:deadbeef:value-b|${workspaceB}` as EncryptedString,
description: '',
isSecret: true,
applicationId: 'app-1',
@@ -133,7 +131,7 @@ describe('buildEnvVar', () => {
{
id: '1',
key: 'NULL_VALUE',
value: null as unknown as string,
value: null as unknown as EncryptedString | '',
description: '',
isSecret: false,
applicationId: 'app-1',
@@ -146,7 +144,7 @@ describe('buildEnvVar', () => {
{
id: '2',
key: 'UNDEFINED_VALUE',
value: undefined as unknown as string,
value: undefined as unknown as EncryptedString | '',
description: '',
isSecret: false,
applicationId: 'app-1',
@@ -171,7 +169,7 @@ describe('buildEnvVar', () => {
{
id: '1',
key: 'NUMBER_VALUE',
value: 123 as unknown as string,
value: 123 as unknown as EncryptedString | '',
description: '',
isSecret: false,
applicationId: 'app-1',
@@ -1,7 +1,9 @@
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { isNonEmptyString } from '@sniptt/guards';
import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util';
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
export const buildEnvVar = (
flatApplicationVariables: FlatApplicationVariable[],
secretEncryptionService: SecretEncryptionService,
@@ -10,8 +12,10 @@ export const buildEnvVar = (
(acc, flatApplicationVariable) => {
const value = String(flatApplicationVariable.value ?? '');
// TODO: After 2-9 slow instance command has run everywhere, turn
// the else branch into an invariant violation for non-empty values.
acc[flatApplicationVariable.key] =
flatApplicationVariable.isSecret && isNonEmptyString(value)
isNonEmptyString(value) && isEncryptedString(value)
? secretEncryptionService.decryptVersioned(value, {
workspaceId: flatApplicationVariable.workspaceId,
})