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,7 +3,6 @@ import { Injectable } from '@nestjs/common';
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface';
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { resolveUniversalUpdateRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-update-relation-identifiers-to-ids.util';
import {
@@ -20,9 +19,7 @@ export class UpdateApplicationVariableActionHandlerService extends WorkspaceMigr
'update',
'applicationVariable',
) {
constructor(
private readonly secretEncryptionService: SecretEncryptionService,
) {
constructor() {
super();
}
@@ -50,6 +47,8 @@ export class UpdateApplicationVariableActionHandlerService extends WorkspaceMigr
};
}
// Value is always encrypted regardless of isSecret, so toggling
// isSecret does not require re-encrypting or decrypting the stored value.
async executeForMetadata(
context: WorkspaceMigrationActionRunnerContext<FlatUpdateApplicationVariableAction>,
): Promise<void> {
@@ -60,34 +59,6 @@ export class UpdateApplicationVariableActionHandlerService extends WorkspaceMigr
ApplicationVariableEntity,
);
const existing = await applicationVariableRepository.findOne({
where: { id: entityId, workspaceId },
});
if (
update.isSecret !== undefined &&
update.isSecret &&
existing &&
!existing.isSecret
) {
(update as Record<string, unknown>).value =
this.secretEncryptionService.encryptVersioned(existing.value, {
workspaceId,
});
}
if (
update.isSecret !== undefined &&
!update.isSecret &&
existing &&
existing.isSecret
) {
(update as Record<string, unknown>).value =
this.secretEncryptionService.decryptVersioned(existing.value, {
workspaceId,
});
}
await applicationVariableRepository.update(
{ id: entityId, workspaceId },
update,