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:
+3
@@ -0,0 +1,3 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export const ROTATE_SIGNING_KEYS_CRON_PATTERN = '15 3 * * *';
|
||||
+35
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+55
@@ -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 {}
|
||||
|
||||
+39
-6
@@ -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();
|
||||
|
||||
+65
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user