From 91ce59d8e2367c6436f68e47a960edcd6b3d25d1 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Sat, 23 May 2026 11:51:06 +0200 Subject: [PATCH] refactor(jwt): gate signing-key auto-rotation cron on SIGNING_KEY_ROTATION_DAYS (#20866) Only register the JWT signing-key rotation cron when `SIGNING_KEY_ROTATION_DAYS` is set, and move that variable to Advanced Settings. --- .../self-host/capabilities/key-rotation.mdx | 5 ++-- .../commands/cron-register-all.command.ts | 23 +++++++++++++++++-- .../twenty-config/config-variables.ts | 4 ++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/twenty-docs/developers/self-host/capabilities/key-rotation.mdx b/packages/twenty-docs/developers/self-host/capabilities/key-rotation.mdx index e5d164c6ff..8f8bc3d4f1 100644 --- a/packages/twenty-docs/developers/self-host/capabilities/key-rotation.mdx +++ b/packages/twenty-docs/developers/self-host/capabilities/key-rotation.mdx @@ -16,10 +16,9 @@ Each key carries a `publicKey` (kept indefinitely so it can verify previously is ### Rotate the current key -- **Manual** — **Settings → Admin Panel → Signing keys → Revoke** on the current row. Revoking wipes its encrypted private material and demotes it; the next sign call automatically mints a fresh ES256 keypair as the new current. Tokens signed under any other (non-revoked) `kid` keep verifying until they expire. -- **Enterprise (automatic)** — a daily cron (`'15 3 * * *'` UTC) issues a new current key once the existing one has been current for `SIGNING_KEY_ROTATION_DAYS` (default `90`). The previous key is *not* revoked, so tokens signed under it keep verifying. Register it once with `yarn command:prod cron:register:all`. +Set `SIGNING_KEY_ROTATION_DAYS` to opt in: a daily cron then issues a new current key once the existing one is older than that threshold. Previous keys are *not* revoked, so tokens signed under them keep verifying. Leave the variable unset to disable auto-rotation. - The Enterprise cron and `SIGNING_KEY_ROTATION_DAYS` ship in v2.6+. +Auto-rotation ships in v2.6+. ### Revoke a key (leak / emergency only) diff --git a/packages/twenty-server/src/database/commands/cron-register-all.command.ts b/packages/twenty-server/src/database/commands/cron-register-all.command.ts index 65b5824dfa..e24b03d9b0 100644 --- a/packages/twenty-server/src/database/commands/cron-register-all.command.ts +++ b/packages/twenty-server/src/database/commands/cron-register-all.command.ts @@ -1,6 +1,7 @@ import { Logger } from '@nestjs/common'; import { Command, CommandRunner } from 'nest-commander'; +import { isDefined } from 'twenty-shared/utils'; import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command'; import { StaleRegistrationCleanupCronCommand } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/commands/stale-registration-cleanup.cron.command'; @@ -10,6 +11,7 @@ import { EventLogCleanupCronCommand } from 'src/engine/core-modules/event-logs/c import { RotateSigningKeysCronCommand } from 'src/engine/core-modules/jwt/crons/commands/rotate-signing-keys.cron.command'; import { CronTriggerCronCommand } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.command'; import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command'; import { TrashCleanupCronCommand } from 'src/engine/trash-cleanup/commands/trash-cleanup.cron.command'; import { CleanOnboardingWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command'; @@ -62,6 +64,7 @@ export class CronRegisterAllCommand extends CommandRunner { private readonly marketplaceCatalogSyncCronCommand: MarketplaceCatalogSyncCronCommand, private readonly applicationVersionCheckCronCommand: ApplicationVersionCheckCronCommand, private readonly staleRegistrationCleanupCronCommand: StaleRegistrationCleanupCronCommand, + private readonly twentyConfigService: TwentyConfigService, ) { super(); } @@ -69,6 +72,10 @@ export class CronRegisterAllCommand extends CommandRunner { async run(): Promise { this.logger.log('Registering all background sync cron jobs...'); + const isSigningKeyAutoRotationEnabled = isDefined( + this.twentyConfigService.get('SIGNING_KEY_ROTATION_DAYS'), + ); + const allCommands = [ { name: 'MessagingMessagesImport', @@ -161,6 +168,7 @@ export class CronRegisterAllCommand extends CommandRunner { { name: 'RotateSigningKeys', command: this.rotateSigningKeysCronCommand, + isEnabled: isSigningKeyAutoRotationEnabled, }, { name: 'StaleRegistrationCleanup', @@ -172,8 +180,15 @@ export class CronRegisterAllCommand extends CommandRunner { let failureCount = 0; const failures: string[] = []; const successes: string[] = []; + const skipped: string[] = []; + + for (const { name, command, isEnabled = true } of allCommands) { + if (!isEnabled) { + this.logger.log(`Skipping ${name} cron job (disabled by config)`); + skipped.push(name); + continue; + } - for (const { name, command } of allCommands) { try { this.logger.log(`Registering ${name} cron job...`); await command.run(); @@ -188,7 +203,7 @@ export class CronRegisterAllCommand extends CommandRunner { } this.logger.log( - `Cron job registration completed: ${successCount} successful, ${failureCount} failed`, + `Cron job registration completed: ${successCount} successful, ${failureCount} failed, ${skipped.length} skipped`, ); if (failures.length > 0) { @@ -198,5 +213,9 @@ export class CronRegisterAllCommand extends CommandRunner { if (successCount > 0) { this.logger.log(`Successful commands: ${successes.join(', ')}`); } + + if (skipped.length > 0) { + this.logger.log(`Skipped commands: ${skipped.join(', ')}`); + } } } diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 71c9c9344c..3cca56c594 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -1185,9 +1185,9 @@ export class ConfigVariables { FALLBACK_ENCRYPTION_KEY: string; @ConfigVariablesMetadata({ - group: ConfigVariablesGroup.SERVER_CONFIG, + group: ConfigVariablesGroup.ADVANCED_SETTINGS, description: - 'Number of days after which the Enterprise auto-rotation cron issues a new current JWT signing key. When unset, the cron is a no-op. Previous keys remain in the database to keep verifying tokens they signed; revocation stays a manual admin action.', + 'Days the current JWT signing key stays valid before the rotation cron issues a new one. Leave unset to disable auto-rotation.', type: ConfigVariableType.NUMBER, }) @CastToPositiveNumber()