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
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { type ConnectedAccountProvider } from 'twenty-shared/types';
import { EntityManager, Repository } from 'typeorm';
import { PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
@@ -18,8 +19,8 @@ export type CreateConnectedAccountInput = {
connectedAccountId: string;
handle: string;
provider: ConnectedAccountProvider;
accessToken: string;
refreshToken: string;
accessToken: PlaintextString;
refreshToken: PlaintextString;
accountOwnerId: string;
scopes: string[];
transactionManager: EntityManager;
@@ -87,6 +88,9 @@ export class CreateConnectedAccountService {
const userWorkspaceId = userWorkspace.id;
// Boundary: tokens entering here were just issued by the external
// OAuth provider (Google / Microsoft / app), so we brand them as
// plaintext before handing them to the encryption service.
const { encryptedAccessToken, encryptedRefreshToken } =
this.connectedAccountTokenEncryptionService.encryptTokenPair({
accessToken,
@@ -8,6 +8,7 @@ import {
MessageChannelVisibility,
} from 'twenty-shared/types';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
@@ -278,8 +279,8 @@ describe('GoogleAPIsService', () => {
userId: 'user-id',
workspaceMemberId: 'workspace-member-id',
workspaceId: 'workspace-id',
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
accessToken: 'new-access-token' as PlaintextString,
refreshToken: 'new-refresh-token' as PlaintextString,
calendarVisibility: CalendarChannelVisibility.SHARE_EVERYTHING,
messageVisibility: MessageChannelVisibility.SHARE_EVERYTHING,
});
@@ -16,6 +16,7 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
@@ -83,8 +84,8 @@ export class GoogleAPIsService {
userId: string;
workspaceMemberId: string;
workspaceId: string;
accessToken: string;
refreshToken: string;
accessToken: PlaintextString;
refreshToken: PlaintextString;
calendarVisibility: CalendarChannelVisibility | undefined;
messageVisibility: MessageChannelVisibility | undefined;
skipMessageChannelConfiguration?: boolean;
@@ -8,6 +8,7 @@ import {
MessageChannelVisibility,
} from 'twenty-shared/types';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
@@ -257,8 +258,8 @@ describe('MicrosoftAPIsService', () => {
userId: 'user-id',
workspaceMemberId: 'workspace-member-id',
workspaceId: 'workspace-id',
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
accessToken: 'new-access-token' as PlaintextString,
refreshToken: 'new-refresh-token' as PlaintextString,
calendarVisibility: CalendarChannelVisibility.SHARE_EVERYTHING,
messageVisibility: MessageChannelVisibility.SHARE_EVERYTHING,
});
@@ -15,6 +15,7 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
@@ -79,8 +80,8 @@ export class MicrosoftAPIsService {
userId: string;
workspaceMemberId: string;
workspaceId: string;
accessToken: string;
refreshToken: string;
accessToken: PlaintextString;
refreshToken: PlaintextString;
calendarVisibility: CalendarChannelVisibility | undefined;
messageVisibility: MessageChannelVisibility | undefined;
skipMessageChannelConfiguration?: boolean;
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@@ -10,8 +11,8 @@ import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system
export type UpdateConnectedAccountOnReconnectInput = {
workspaceId: string;
connectedAccountId: string;
accessToken: string;
refreshToken: string;
accessToken: PlaintextString;
refreshToken: PlaintextString;
scopes: string[];
transactionManager: EntityManager;
};
@@ -34,6 +35,9 @@ export class UpdateConnectedAccountOnReconnectService {
scopes,
} = input;
// Boundary: tokens entering here were just re-issued by the external
// OAuth provider on reconnect, so we brand them as plaintext before
// handing them to the encryption service.
const { encryptedAccessToken, encryptedRefreshToken } =
this.connectedAccountTokenEncryptionService.encryptTokenPair({
accessToken,
@@ -8,6 +8,7 @@ import { parseJson } from 'twenty-shared/utils';
import { GoogleAPIsOauthCommonStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy';
import { type APIsOAuthRequest } from 'src/engine/core-modules/auth/types/apis-oauth-request.type';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { type APIsOAuthState } from 'src/engine/core-modules/auth/types/apis-oauth-state.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -19,8 +20,8 @@ export class GoogleAPIsOauthExchangeCodeForTokenStrategy extends GoogleAPIsOauth
async validate(
request: APIsOAuthRequest,
accessToken: string,
refreshToken: string,
accessToken: PlaintextString,
refreshToken: PlaintextString,
profile: GoogleProfile,
done: VerifyCallback,
): Promise<void> {
@@ -5,6 +5,7 @@ import { parseJson } from 'twenty-shared/utils';
import { MicrosoftAPIsOauthCommonStrategy } from 'src/engine/core-modules/auth/strategies/microsoft-apis-oauth-common.auth.strategy';
import { type APIsOAuthRequest } from 'src/engine/core-modules/auth/types/apis-oauth-request.type';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { type APIsOAuthState } from 'src/engine/core-modules/auth/types/apis-oauth-state.type';
import { type MicrosoftPassportProfile } from 'src/engine/core-modules/auth/types/microsoft-passport-profile.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -17,8 +18,8 @@ export class MicrosoftAPIsOauthExchangeCodeForTokenStrategy extends MicrosoftAPI
async validate(
request: APIsOAuthRequest,
accessToken: string,
refreshToken: string,
accessToken: PlaintextString,
refreshToken: PlaintextString,
profile: MicrosoftPassportProfile,
done: VerifyCallback,
): Promise<void> {
@@ -5,6 +5,8 @@ import {
type MessageChannelVisibility,
} from 'twenty-shared/types';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
export type APIsOAuthRequest = Omit<
Request,
'user' | 'workspace' | 'workspaceMetadataVersion'
@@ -15,8 +17,8 @@ export type APIsOAuthRequest = Omit<
emails: { value: string }[];
picture: string | null;
workspaceInviteHash?: string;
accessToken: string;
refreshToken: string;
accessToken: PlaintextString;
refreshToken: PlaintextString;
transientToken: string;
redirectLocation?: string;
calendarVisibility?: CalendarChannelVisibility;