Files
twenty/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.ts
T
Charles Bochet e0b4c9918b feat(twenty-server): introduce ENCRYPTION_KEY env var with versioned envelope (#20528)
## Summary

- Adds `ENCRYPTION_KEY` (primary) and `FALLBACK_ENCRYPTION_KEY`
(decrypt-only fallback for rotation) env vars to twenty-server, with
backward-compatible fallback to `APP_SECRET` when `ENCRYPTION_KEY` is
unset.
- Introduces a versioned ciphertext envelope `enc:v2:<keyId>:<base64>`
using AES-256-GCM with HKDF-SHA256 derived per-context keys. The 8-hex
`keyId` fingerprint lets every row identify which physical key encrypted
it, so rotation routes directly to primary or fallback without trial
decryption; GCM's auth tag gives true integrity (legacy CTR has none).
- Migrates `ConnectedAccountTokenEncryptionService` to the new envelope
and plumbs `workspaceId` through every caller, so per-workspace HKDF
context binds each row to its tenant.

The remaining encryption sites (`jwt-key-manager`, `config-storage`,
`postgres-credentials`, `application-variable`, TOTP) stay on the legacy
unprefixed CTR path and will be migrated in follow-up PRs. The
operator-facing rotation runbook is out of scope here.

### Format details

`enc:v{N}:{keyId}:{base64}` — `N=2` is the only version produced by new
writes (`v1` exists for backward-compatible decryption of existing
connected-account rows). `keyId =
sha256(rawKey).slice(0,4).toString('hex')`. The CHECK constraint on
`core.connectedAccount.{accessToken,refreshToken}` is relaxed from `LIKE
'enc:v1:%'` to `LIKE 'enc:v_:%'` so both versions pass.

### Key resolution

| `ENCRYPTION_KEY` | `FALLBACK_ENCRYPTION_KEY` | `APP_SECRET` | Encrypt
with | Decrypt try order |
|---|---|---|---|---|
| set | set | (any) | `ENCRYPTION_KEY` | match `keyId` → primary →
fallback |
| set | unset | (any) | `ENCRYPTION_KEY` | match `keyId` → primary |
| unset | set | set | `APP_SECRET` | match `keyId` → `APP_SECRET` →
fallback |
| unset | unset | set | `APP_SECRET` | match `keyId` → `APP_SECRET` |
| unset | unset | unset | startup error | n/a |

## Test plan

- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx jest
'secret-encryption|connected-account-token-encryption|connected-account-refresh-tokens|encrypt-connected-account-tokens|connection-provider-oauth-flow'`
— 87 tests pass
- [x] New `secret-encryption.service.versioned.spec.ts` covers: key
resolution table (no-key error, APP_SECRET fallback, ENCRYPTION_KEY
precedence), v2 round-trip with/without workspaceId, GCM tamper
rejection, workspaceId-mismatch rejection, keyId-based primary→fallback
routing, missing-key error names the fingerprint, v1 legacy decryption,
no-prefix legacy decryption, malformed envelope rejection.
- [x] Updated `connected-account-token-encryption.service.spec.ts`
covers workspaceId binding and HKDF context isolation.
- [x] Updated slow instance command spec verifies workspaceId is
threaded through encryption and the relaxed `enc:v_:%` LIKE pattern
matches both v1 and v2.
- [ ] Manual E2E: connect a Gmail account on a freshly deployed instance
with `APP_SECRET` only → confirm `core.connectedAccount.accessToken` is
`enc:v2:<keyId>:<base64>`.
- [ ] Manual E2E: rotate — set `ENCRYPTION_KEY=<new>` and
`FALLBACK_ENCRYPTION_KEY=<old APP_SECRET>`, restart, confirm
pre-rotation rows still decrypt and new rows carry the new `keyId`.
- [ ] Manual E2E: missing key — set `ENCRYPTION_KEY=<new>` without the
fallback, confirm decrypt error names the old `keyId` so the operator
can identify the missing key.
2026-05-13 16:15:54 +00:00

194 lines
6.5 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
ConnectedAccountProvider,
MessageFolderPendingSyncAction,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import {
DiscoveredMessageFolder,
MessageFolder,
} from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GmailGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/gmail/services/gmail-get-all-folders.service';
import { ImapGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/imap/services/imap-get-all-folders.service';
import { MicrosoftGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/microsoft/services/microsoft-get-all-folders.service';
import { computeFolderIdsToDelete } from 'src/modules/messaging/message-folder-manager/utils/compute-folder-ids-to-delete.util';
import { computeFoldersToCreate } from 'src/modules/messaging/message-folder-manager/utils/compute-folders-to-create.util';
import { computeFoldersToUpdate } from 'src/modules/messaging/message-folder-manager/utils/compute-folders-to-update.util';
import { computeUpdatedFolders } from 'src/modules/messaging/message-folder-manager/utils/compute-updated-folders.util';
@Injectable()
export class SyncMessageFoldersService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
@InjectRepository(MessageFolderEntity)
private readonly messageFolderRepository: Repository<MessageFolderEntity>,
private readonly gmailGetAllFoldersService: GmailGetAllFoldersService,
private readonly microsoftGetAllFoldersService: MicrosoftGetAllFoldersService,
private readonly imapGetAllFoldersService: ImapGetAllFoldersService,
) {}
async syncMessageFolders({
messageChannel,
workspaceId,
}: {
messageChannel: Pick<
MessageChannelEntity,
'id' | 'messageFolderImportPolicy'
> & {
connectedAccount: Pick<
ConnectedAccountEntity,
| 'provider'
| 'accessToken'
| 'refreshToken'
| 'id'
| 'handle'
| 'connectionParameters'
| 'workspaceId'
>;
messageFolders: MessageFolder[];
};
workspaceId: string;
}): Promise<MessageFolder[]> {
const discoveredFolders = await this.discoverAllFolders(
messageChannel.connectedAccount,
messageChannel,
);
const { messageFolders: existingFolders, id: messageChannelId } =
messageChannel;
return this.syncFolderChanges(
discoveredFolders,
existingFolders,
messageChannelId,
workspaceId,
);
}
async discoverAllFolders(
connectedAccount: Pick<
ConnectedAccountEntity,
| 'accessToken'
| 'refreshToken'
| 'id'
| 'handle'
| 'provider'
| 'connectionParameters'
| 'workspaceId'
>,
messageChannel: Pick<MessageChannelEntity, 'messageFolderImportPolicy'>,
): Promise<DiscoveredMessageFolder[]> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return this.gmailGetAllFoldersService.getAllMessageFolders(
connectedAccount,
messageChannel,
);
case ConnectedAccountProvider.MICROSOFT:
return this.microsoftGetAllFoldersService.getAllMessageFolders(
connectedAccount,
messageChannel,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return this.imapGetAllFoldersService.getAllMessageFolders(
connectedAccount,
messageChannel,
);
default:
throw new Error(
`Provider ${connectedAccount.provider} is not supported`,
);
}
}
private async syncFolderChanges(
discoveredFolders: DiscoveredMessageFolder[],
existingFolders: MessageFolder[],
messageChannelId: string,
workspaceId: string,
): Promise<MessageFolder[]> {
const foldersToCreate = computeFoldersToCreate({
discoveredFolders,
existingFolders,
messageChannelId,
});
const foldersToUpdate = computeFoldersToUpdate({
discoveredFolders,
existingFolders,
});
const folderIdsToDelete = computeFolderIdsToDelete({
discoveredFolders,
existingFolders,
});
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
if (folderIdsToDelete.length > 0) {
await this.messageFolderRepository.update(
{ id: In(folderIdsToDelete), workspaceId },
{
pendingSyncAction: MessageFolderPendingSyncAction.FOLDER_DELETION,
},
);
}
if (foldersToUpdate.size > 0) {
for (const [id, data] of foldersToUpdate.entries()) {
await this.messageFolderRepository.update(
{ id, messageChannelId, workspaceId },
data as Record<string, unknown>,
);
}
}
if (foldersToCreate.length > 0) {
for (const folderToCreate of foldersToCreate) {
await this.messageFolderRepository.save({
...folderToCreate,
workspaceId,
});
}
}
const createdFolders =
foldersToCreate.length > 0
? await this.messageFolderRepository.find({
where: {
messageChannelId,
externalId: In(
foldersToCreate
.map((folder) => folder.externalId)
.filter(isDefined),
),
workspaceId,
},
})
: [];
const updatedExistingFolders = computeUpdatedFolders({
existingFolders,
foldersToUpdate,
folderIdsToDelete,
});
return [...updatedExistingFolders, ...createdFolders];
},
authContext,
);
}
}