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:
+6
-2
@@ -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,
|
||||
|
||||
+3
-2
@@ -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;
|
||||
|
||||
+3
-2
@@ -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,
|
||||
});
|
||||
|
||||
+3
-2
@@ -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;
|
||||
|
||||
+6
-2
@@ -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,
|
||||
|
||||
+3
-2
@@ -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> {
|
||||
|
||||
+3
-2
@@ -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> {
|
||||
|
||||
+4
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user