feat(server): Enterprise cron that rotates the current JWT signing key (#20612)

## Summary
Adds a daily Enterprise-only cron that rotates the current ES256 JWT
signing key once it has been current for `SIGNING_KEY_ROTATION_DAYS`.
Manual rotation from the admin panel is unaffected.

### Behaviour
- `SIGNING_KEY_ROTATION_DAYS` is **opt-in**: when unset, the cron is a
no-op.
- Rotation flips `isCurrent` and clears the previous key's `privateKey`
in the same transaction, then inserts the new `isCurrent=true` row.
- The previous key's row is kept (`revokedAt` stays `null`) so its
`publicKey` can keep verifying tokens it signed until they expire; only
the encrypted `privateKey` is wiped since it can no longer be used to
sign.
- **No auto-revocation** — revoking a key remains a manual admin action,
reserved for leak / emergency response.
- The cron is also a no-op when `EnterprisePlanService.isValid()` is
`false`.

### Wiring
- `JwtKeyManagerService.rotateCurrent()`
- `SigningKeyRotationService.rotateIfDue()` (reads
`SIGNING_KEY_ROTATION_DAYS`, skips when unset)
- `RotateSigningKeysCronJob` (Enterprise-gated, rethrows on failure)
registered in `JwtModule`
- `RotateSigningKeysCronCommand` registered with `cron:register:all`
- `ROTATE_SIGNING_KEYS_CRON_PATTERN = '15 3 * * *'` (daily, no-op until
threshold)

Operator documentation lives in #20611 (docs PR).
This commit is contained in:
Charles Bochet
2026-05-19 12:41:04 +02:00
committed by GitHub
parent 6cd069ce40
commit 72ce77864e
13 changed files with 495 additions and 174 deletions
@@ -0,0 +1,3 @@
/* @license Enterprise */
export const ROTATE_SIGNING_KEYS_CRON_PATTERN = '15 3 * * *';
@@ -0,0 +1,35 @@
/* @license Enterprise */
import { Command, CommandRunner } from 'nest-commander';
import { ROTATE_SIGNING_KEYS_CRON_PATTERN } from 'src/engine/core-modules/jwt/constants/rotate-signing-keys-cron-pattern.constant';
import { RotateSigningKeysCronJob } from 'src/engine/core-modules/jwt/crons/jobs/rotate-signing-keys.cron.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@Command({
name: 'cron:rotate-signing-keys',
description:
'Starts a daily cron job that issues a fresh current JWT signing key once SIGNING_KEY_ROTATION_DAYS has elapsed. Enterprise-only.',
})
export class RotateSigningKeysCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
async run(): Promise<void> {
await this.messageQueueService.addCron<undefined>({
jobName: RotateSigningKeysCronJob.name,
data: undefined,
options: {
repeat: {
pattern: ROTATE_SIGNING_KEYS_CRON_PATTERN,
},
},
});
}
}
@@ -0,0 +1,55 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
import { ROTATE_SIGNING_KEYS_CRON_PATTERN } from 'src/engine/core-modules/jwt/constants/rotate-signing-keys-cron-pattern.constant';
import { SigningKeyRotationService } from 'src/engine/core-modules/jwt/services/signing-key-rotation.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@Injectable()
@Processor(MessageQueue.cronQueue)
export class RotateSigningKeysCronJob {
private readonly logger = new Logger(RotateSigningKeysCronJob.name);
constructor(
private readonly enterprisePlanService: EnterprisePlanService,
private readonly signingKeyRotationService: SigningKeyRotationService,
) {}
@Process(RotateSigningKeysCronJob.name)
@SentryCronMonitor(
RotateSigningKeysCronJob.name,
ROTATE_SIGNING_KEYS_CRON_PATTERN,
)
async handle(): Promise<void> {
if (!this.enterprisePlanService.isValid()) {
this.logger.log(
'Enterprise plan not valid, skipping signing key rotation',
);
return;
}
try {
const result = await this.signingKeyRotationService.rotateIfDue();
if (result.rotated) {
this.logger.log(
`Rotated current signing key: ${result.previousId} -> ${result.newId}`,
);
}
} catch (error) {
this.logger.error(
`Signing key rotation failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
throw error;
}
}
}
@@ -3,14 +3,17 @@ import { JwtModule as NestJwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import {
JWT_LEGACY_ALGORITHM,
JWT_SUPPORTED_VERIFY_ALGORITHMS,
} from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant';
import { RotateSigningKeysCronJob } from 'src/engine/core-modules/jwt/crons/jobs/rotate-signing-keys.cron.job';
import { SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-key.entity';
import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-key-manager.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SigningKeyEntityCacheProviderService } from 'src/engine/core-modules/jwt/services/signing-key-entity-cache-provider.service';
import { SigningKeyRotationService } from 'src/engine/core-modules/jwt/services/signing-key-rotation.service';
import { SigningKeyVerifyCounterService } from 'src/engine/core-modules/jwt/services/signing-key-verify-counter.service';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
@@ -39,6 +42,7 @@ const InternalJwtModule = NestJwtModule.registerAsync({
TypeOrmModule.forFeature([SigningKeyEntity]),
CoreEntityCacheModule,
SecretEncryptionModule,
EnterpriseModule,
],
controllers: [],
providers: [
@@ -46,11 +50,14 @@ const InternalJwtModule = NestJwtModule.registerAsync({
JwtKeyManagerService,
SigningKeyEntityCacheProviderService,
SigningKeyVerifyCounterService,
SigningKeyRotationService,
RotateSigningKeysCronJob,
],
exports: [
JwtWrapperService,
JwtKeyManagerService,
SigningKeyVerifyCounterService,
SigningKeyRotationService,
],
})
export class JwtModule {}
@@ -52,14 +52,12 @@ export class JwtKeyManagerService {
const result = await this.currentSigningKeyPromise;
if (!isDefined(result)) {
this.currentSigningKeyPromise = null;
this.currentSigningKeyCachedAt = 0;
this.invalidateCurrentSigningKeyLocalCache();
}
return result;
} catch (error) {
this.currentSigningKeyPromise = null;
this.currentSigningKeyCachedAt = 0;
this.invalidateCurrentSigningKeyLocalCache();
throw error;
}
}
@@ -78,6 +76,37 @@ export class JwtKeyManagerService {
});
}
async rotateCurrent(): Promise<CurrentSigningKey> {
const generated = this.generateEcP256KeyPair();
const newId = randomUUID();
await this.signingKeyRepository.manager.transaction(
async (entityManager) => {
const repository = entityManager.getRepository(SigningKeyEntity);
await repository.update(
{ isCurrent: true },
{ isCurrent: false, privateKey: null },
);
await repository.insert({
id: newId,
publicKey: generated.publicKeyPem,
privateKey: this.secretEncryptionService.encryptVersioned(
generated.privateKeyPem,
),
isCurrent: true,
revokedAt: null,
});
},
);
await this.coreEntityCacheService.invalidate('signingKeyPublicKey', newId);
this.invalidateCurrentSigningKeyLocalCache();
return { id: newId, privateKeyPem: generated.privateKeyPem };
}
async revokeSigningKey(id: string): Promise<SigningKeyEntity> {
if (!isNonEmptyString(id) || !isValidUuid(id)) {
throw new JwtKeyManagerException(
@@ -107,12 +136,16 @@ export class JwtKeyManagerService {
}
await this.coreEntityCacheService.invalidate('signingKeyPublicKey', id);
this.currentSigningKeyPromise = null;
this.currentSigningKeyCachedAt = 0;
this.invalidateCurrentSigningKeyLocalCache();
return this.signingKeyRepository.findOneByOrFail({ id });
}
private invalidateCurrentSigningKeyLocalCache(): void {
this.currentSigningKeyPromise = null;
this.currentSigningKeyCachedAt = 0;
}
private async loadOrCreateCurrentSigningKey(): Promise<CurrentSigningKey | null> {
try {
const existing = await this.findCurrentSigningKeyRow();
@@ -0,0 +1,65 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-key-manager.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
export type SigningKeyRotationResult = {
rotated: boolean;
previousId: string | null;
newId: string | null;
};
@Injectable()
export class SigningKeyRotationService {
private readonly logger = new Logger(SigningKeyRotationService.name);
constructor(
private readonly jwtKeyManagerService: JwtKeyManagerService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async rotateIfDue(): Promise<SigningKeyRotationResult> {
const rotationDays = this.twentyConfigService.get(
'SIGNING_KEY_ROTATION_DAYS',
);
if (!isDefined(rotationDays)) {
this.logger.log(
'SIGNING_KEY_ROTATION_DAYS is not configured, skipping signing key rotation',
);
return { rotated: false, previousId: null, newId: null };
}
const signingKeys = await this.jwtKeyManagerService.listSigningKeys();
const current = signingKeys.find(
(signingKey) => signingKey.isCurrent && !isDefined(signingKey.revokedAt),
);
if (!isDefined(current)) {
return { rotated: false, previousId: null, newId: null };
}
const ageDays = (Date.now() - current.createdAt.getTime()) / ONE_DAY_MS;
if (ageDays < rotationDays) {
this.logger.log(
`Current signing key ${current.id} is ${ageDays.toFixed(
2,
)} days old, rotation threshold is ${rotationDays} days, skipping`,
);
return { rotated: false, previousId: current.id, newId: null };
}
const next = await this.jwtKeyManagerService.rotateCurrent();
return { rotated: true, previousId: current.id, newId: next.id };
}
}
@@ -1165,6 +1165,16 @@ export class ConfigVariables {
@IsOptional()
FALLBACK_ENCRYPTION_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
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.',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@IsOptional()
SIGNING_KEY_ROTATION_DAYS?: number;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.RATE_LIMITING,
description: 'Maximum number of records affected by mutations',