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
-4
@@ -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;
|
||||
|
||||
+39
-24
@@ -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 {
|
||||
|
||||
+30
-11
@@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user