Strictly type encryption rotation key site maps constants through entity type derivation (#21085)

# Introduction
Followup https://github.com/twentyhq/twenty/pull/21001

Now that the typeorm entities provide grains over their
`encryptedString` value, we can strictly type the sitemaps of the
encrypted string to rotate in case of encryption key rotation and also
the integration tests tests cases
This commit is contained in:
Paul Rastoin
2026-06-01 17:25:58 +02:00
committed by GitHub
parent d86e827563
commit 989b45db15
21 changed files with 422 additions and 187 deletions
@@ -0,0 +1,108 @@
import { type Equal, type Expect } from 'twenty-shared/testing';
import { type EmptyObject } from 'twenty-shared/types';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { type ExtractEncryptedColumns } from 'src/engine/core-modules/secret-encryption/branded-strings/extract-encrypted-columns.type';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
type EncryptedConnectionParametersLike = {
IMAP?: { host: string; password: EncryptedString };
SMTP?: { host: string; password: EncryptedString };
};
type DeeplyNestedThreeLevels = {
level1: {
level2: {
level3: EncryptedString;
};
};
};
class FakeRelatedEntity extends WorkspaceRelatedEntity {
fakeId: string;
encryptedTokenOnRelatedEntity: EncryptedString | null;
}
type TestedRecord = {
// Non-EncryptedString fields - must NOT be extracted
plainString: string;
plainStringNullable: string | null;
plainNumber: number;
plainBoolean: boolean;
plainDate: Date;
plainObject: EmptyObject;
plainArray: string[];
plainUnknown: unknown;
plaintextBranded: PlaintextString;
plaintextBrandedNullable: PlaintextString | null;
emptyStringLiteral: '';
encVersionedLiteral: 'enc:v2:xxx';
recordWithoutEncryption: { host: string; port: number };
arrayOfPlaintextRecords: Array<{ host: string }>;
// Direct EncryptedString fields - MUST be extracted
encryptedRequired: EncryptedString;
encryptedNullable: EncryptedString | null;
encryptedUndefinable: EncryptedString | undefined;
encryptedOptional?: EncryptedString;
encryptedOrEmpty: EncryptedString | '';
encryptedUnionWithPrimitive: EncryptedString | string;
encryptedUnionWithPlaintext: EncryptedString | PlaintextString;
// Nested EncryptedString — MUST be extracted (transitive structural)
connectionParametersLike: EncryptedConnectionParametersLike;
connectionParametersLikeNullable: EncryptedConnectionParametersLike | null;
arrayOfRecordsWithEncrypted: Array<{ secret: EncryptedString }>;
recordWithEncryptedAtTopLevel: { secret: EncryptedString };
deeplyNested: DeeplyNestedThreeLevels;
// Entity relations — MUST NOT be extracted, even though the related
// entity carries an EncryptedString column. Stripped by
// `ExtractEntityRelatedEntityProperties` before recursion.
relatedEntity: FakeRelatedEntity;
relatedEntityNullable: FakeRelatedEntity | null;
relatedEntityArray: FakeRelatedEntity[];
};
type TestResult = ExtractEncryptedColumns<TestedRecord>;
// oxlint-disable-next-line unused-imports/no-unused-vars
type Assertions = [
Expect<
Equal<
TestResult,
| 'encryptedRequired'
| 'encryptedNullable'
| 'encryptedUndefinable'
| 'encryptedOptional'
| 'encryptedOrEmpty'
| 'encryptedUnionWithPrimitive'
| 'encryptedUnionWithPlaintext'
| 'connectionParametersLike'
| 'connectionParametersLikeNullable'
| 'arrayOfRecordsWithEncrypted'
| 'recordWithEncryptedAtTopLevel'
| 'deeplyNested'
>
>,
// Empty object returns never
Expect<Equal<ExtractEncryptedColumns<EmptyObject>, never>>,
// Object with no EncryptedString fields returns never
Expect<
Equal<ExtractEncryptedColumns<{ a: string; b: number; c: '' }>, never>
>,
// Object with only PlaintextString fields returns never (brands don't cross)
Expect<
Equal<
ExtractEncryptedColumns<{
a: PlaintextString;
b: PlaintextString | null;
}>,
never
>
>,
];
@@ -0,0 +1,13 @@
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
export type ContainsEncryptedString<T> = T extends EncryptedString
? true
: T extends ReadonlyArray<infer U>
? ContainsEncryptedString<U>
: T extends object
? true extends {
[K in keyof T]: ContainsEncryptedString<NonNullable<T[K]>>;
}[keyof T]
? true
: false
: false;
@@ -0,0 +1,14 @@
import { type ContainsEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/contains-encrypted-string.type';
import { type ExtractEntityRelatedEntityProperties } from 'src/engine/metadata-modules/flat-entity/types/extract-entity-related-entity-properties.type';
type ExtractEncryptedColumnsFromShape<T> = NonNullable<
{
[P in keyof T]-?: true extends ContainsEncryptedString<NonNullable<T[P]>>
? P
: never;
}[keyof T]
>;
export type ExtractEncryptedColumns<T> = ExtractEncryptedColumnsFromShape<
Omit<T, ExtractEntityRelatedEntityProperties<T>>
>;
@@ -1,3 +1,5 @@
export { type ContainsEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/contains-encrypted-string.type';
export { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
export { type ExtractEncryptedColumns } from 'src/engine/core-modules/secret-encryption/branded-strings/extract-encrypted-columns.type';
export { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util';
export { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';