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:
+5
-3
@@ -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;
|
||||
|
||||
+14
-16
@@ -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',
|
||||
|
||||
+7
-3
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user