feat(core): Persist deployment_key entries for stability across restarts and key rotation (#28518)
Some checks are pending
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions

This commit is contained in:
Stephen Wright 2026-04-16 20:49:11 +01:00 committed by GitHub
parent c97c3b4d12
commit bb96d2e50a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 486 additions and 17 deletions

View file

@ -16,4 +16,16 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
async findAllByType(type: string): Promise<DeploymentKey[]> {
return await this.find({ where: { type } });
}
/**
* Inserts the entity if no active row with that type exists yet.
* On a unique-index conflict (concurrent multi-main startup), the insert
* is silently ignored. The caller should read the winner's value afterwards.
*/
async insertOrIgnore(
entityData: Pick<DeploymentKey, 'type' | 'value' | 'status' | 'algorithm'>,
): Promise<void> {
const entity = this.create(entityData);
await this.createQueryBuilder().insert().values(entity).orIgnore().execute();
}
}

View file

@ -2,12 +2,15 @@
import '@/zod-alias-support';
import { mockInstance } from '@n8n/backend-test-utils';
import { AuthRolesService, DbConnection } from '@n8n/db';
import { AuthRolesService, DbConnection, DeploymentKeyRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { InstanceSettings } from 'n8n-core';
import { BinaryDataConfig } from 'n8n-core';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import { JwtService } from '@/services/jwt.service';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { AuthHandlerRegistry } from '@/auth/auth-handler.registry';
import { DeprecationService } from '@/deprecation/deprecation.service';
@ -32,6 +35,10 @@ import { TaskRunnerModule } from '@/task-runners/task-runner-module';
const authRolesService = mockInstance(AuthRolesService);
authRolesService.init.mockResolvedValue(undefined);
const deploymentKeyRepository = mockInstance(DeploymentKeyRepository);
deploymentKeyRepository.findActiveByType.mockResolvedValue(null);
deploymentKeyRepository.insertOrIgnore.mockResolvedValue(undefined);
const loadNodesAndCredentials = mockInstance(LoadNodesAndCredentials);
loadNodesAndCredentials.init.mockResolvedValue(undefined);
loadNodesAndCredentials.postProcessLoaders.mockResolvedValue(undefined);
@ -118,6 +125,15 @@ describe('Start - AuthRolesService initialization', () => {
Container.set(CommunityPackagesConfig, mockInstance(CommunityPackagesConfig));
Container.set(CommunityPackagesService, communityPackagesService);
Container.set(TaskRunnerModule, taskRunnerModule);
Container.set(DeploymentKeyRepository, deploymentKeyRepository);
Container.set(
JwtService,
mockInstance(JwtService, { initialize: jest.fn().mockResolvedValue(undefined) }),
);
Container.set(
BinaryDataConfig,
mockInstance(BinaryDataConfig, { initialize: jest.fn().mockResolvedValue(undefined) }),
);
start = new Start();
// @ts-expect-error - Accessing protected property for testing

View file

@ -1,4 +1,5 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { DbConnection, DeploymentKeyRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
@ -13,6 +14,14 @@ jest.mock('@/scaling/scaling.service', () => ({
ScalingService: class {},
}));
const dbConnection = mockInstance(DbConnection);
dbConnection.init.mockResolvedValue(undefined);
dbConnection.migrate.mockResolvedValue(undefined);
const deploymentKeyRepository = mockInstance(DeploymentKeyRepository);
deploymentKeyRepository.findActiveByType.mockResolvedValue(null);
deploymentKeyRepository.insertOrIgnore.mockResolvedValue(undefined);
mockInstance(RedisClientService);
mockInstance(PubSubRegistry);
const mockSubscriber = mockInstance(Subscriber);

View file

@ -1,5 +1,6 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { DbConnection, DeploymentKeyRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
@ -11,6 +12,14 @@ import { RedisClientService } from '@/services/redis-client.service';
import { Worker } from '../worker';
const dbConnection = mockInstance(DbConnection);
dbConnection.init.mockResolvedValue(undefined);
dbConnection.migrate.mockResolvedValue(undefined);
const deploymentKeyRepository = mockInstance(DeploymentKeyRepository);
deploymentKeyRepository.findActiveByType.mockResolvedValue(null);
deploymentKeyRepository.insertOrIgnore.mockResolvedValue(undefined);
mockInstance(RedisClientService);
mockInstance(PubSubRegistry);
const mockSubscriber = mockInstance(Subscriber);

View file

@ -1,13 +1,19 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { LICENSE_FEATURES } from '@n8n/constants';
import { AuthRolesService, ExecutionRepository, SettingsRepository } from '@n8n/db';
import {
AuthRolesService,
DeploymentKeyRepository,
ExecutionRepository,
SettingsRepository,
} from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { McpServer } from '@n8n/n8n-nodes-langchain/mcp/core';
import glob from 'fast-glob';
import { createReadStream, createWriteStream, existsSync } from 'fs';
import { mkdir } from 'fs/promises';
import { BinaryDataConfig } from 'n8n-core';
import { jsonParse, sleep, type IWorkflowExecutionDataProcess } from 'n8n-workflow';
import path from 'path';
import replaceStream from 'replacestream';
@ -27,6 +33,7 @@ import { Publisher } from '@/scaling/pubsub/publisher.service';
import { PubSubRegistry } from '@/scaling/pubsub/pubsub.registry';
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import { Server } from '@/server';
import { JwtService } from '@/services/jwt.service';
import { OwnershipService } from '@/services/ownership.service';
import { ExecutionsPruningService } from '@/services/pruning/executions-pruning.service';
import { UrlService } from '@/services/url.service';
@ -224,6 +231,10 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
await this.initOrchestration();
}
await this.instanceSettings.initialize(Container.get(DeploymentKeyRepository));
await Container.get(JwtService).initialize(Container.get(DeploymentKeyRepository));
await Container.get(BinaryDataConfig).initialize(Container.get(DeploymentKeyRepository));
await this.initLicense();
if (isMultiMainEnabled) {

View file

@ -1,14 +1,17 @@
import { DeploymentKeyRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { BinaryDataConfig } from 'n8n-core';
import { ActiveExecutions } from '@/active-executions';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { DeprecationService } from '@/deprecation/deprecation.service';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { PubSubRegistry } from '@/scaling/pubsub/pubsub.registry';
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import { JwtService } from '@/services/jwt.service';
import { WebhookServer } from '@/webhooks/webhook-server';
import { BaseCommand } from './base-command';
@ -67,6 +70,10 @@ export class Webhook extends BaseCommand {
await super.init();
Container.get(DeprecationService).warn();
await this.instanceSettings.initialize(Container.get(DeploymentKeyRepository));
await Container.get(JwtService).initialize(Container.get(DeploymentKeyRepository));
await Container.get(BinaryDataConfig).initialize(Container.get(DeploymentKeyRepository));
await this.initLicense();
this.logger.debug('License init complete');
await this.initCommunityPackages();

View file

@ -1,6 +1,8 @@
import { inTest } from '@n8n/backend-common';
import { DeploymentKeyRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { BinaryDataConfig } from 'n8n-core';
import { z } from 'zod';
import { N8N_VERSION } from '@/constants';
@ -9,15 +11,16 @@ import { DeprecationService } from '@/deprecation/deprecation.service';
import { EventMessageGeneric } from '@/eventbus/event-message-classes/event-message-generic';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { PubSubRegistry } from '@/scaling/pubsub/pubsub.registry';
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import type { ScalingService } from '@/scaling/scaling.service';
import type { WorkerServerEndpointsConfig } from '@/scaling/worker-server';
import { WorkerStatusService } from '@/scaling/worker-status.service.ee';
import { JwtService } from '@/services/jwt.service';
import { BaseCommand } from './base-command';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
const flagsSchema = z.object({
concurrency: z.number().int().default(10).describe('How many jobs can run in parallel.'),
@ -90,6 +93,10 @@ export class Worker extends BaseCommand<z.infer<typeof flagsSchema>> {
Container.get(DeprecationService).warn();
await this.instanceSettings.initialize(Container.get(DeploymentKeyRepository));
await Container.get(JwtService).initialize(Container.get(DeploymentKeyRepository));
await Container.get(BinaryDataConfig).initialize(Container.get(DeploymentKeyRepository));
await this.initLicense();
this.logger.debug('License init complete');
await this.initCommunityPackages();

View file

@ -4,7 +4,6 @@ import { ApiKeyRepository, UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { DateTime } from 'luxon';
import type { InstanceSettings } from 'n8n-core';
import { randomString } from 'n8n-workflow';
import { createOwnerWithApiKey } from '@test-integration/db/users';
@ -33,10 +32,7 @@ const mockReqWithoutApiKey = (path: string, method: string): AuthenticatedReques
return req;
};
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());
let jwtService: JwtService;
let strategy: ApiKeyAuthStrategy;
describe('ApiKeyAuthStrategy', () => {
@ -46,6 +42,7 @@ describe('ApiKeyAuthStrategy', () => {
beforeAll(async () => {
await testDb.init();
jwtService = Container.get(JwtService);
strategy = new ApiKeyAuthStrategy(Container.get(ApiKeyRepository), jwtService);
});

View file

@ -5,6 +5,8 @@ import type { InstanceSettings } from 'n8n-core';
import { JwtService } from '@/services/jwt.service';
const getJwtSecret = (svc: JwtService) => (svc as unknown as { jwtSecret: string }).jwtSecret;
describe('JwtService', () => {
const iat = 1699984313;
const jwtSecret = 'random-string';
@ -30,13 +32,13 @@ describe('JwtService', () => {
it('should read the secret from config, when set', () => {
globalConfig.userManagement.jwtSecret = jwtSecret;
const jwtService = new JwtService(instanceSettings, globalConfig);
expect(jwtService.jwtSecret).toEqual(jwtSecret);
expect(getJwtSecret(jwtService)).toEqual(jwtSecret);
});
it('should derive the secret from encryption key when not set in config', () => {
globalConfig.userManagement.jwtSecret = '';
const jwtService = new JwtService(instanceSettings, globalConfig);
expect(jwtService.jwtSecret).toEqual(
expect(getJwtSecret(jwtService)).toEqual(
'e9e2975005eddefbd31b2c04a0b0f2d9c37d9d718cf3676cddf76d65dec555cb',
);
});
@ -72,4 +74,71 @@ describe('JwtService', () => {
expect(() => jwtService.verify(expiredToken)).toThrow(jwt.TokenExpiredError);
});
});
describe('initialize()', () => {
const makeRepo = () =>
mock<{
findActiveByType(type: string): Promise<{ value: string } | null>;
insertOrIgnore(entity: {
type: string;
value: string;
status: string;
algorithm: null;
}): Promise<void>;
}>();
it('should use jwtSecret from config and skip DB entirely when set', async () => {
globalConfig.userManagement.jwtSecret = 'env-pinned-secret';
const repo = makeRepo();
const jwtService = new JwtService(instanceSettings, globalConfig);
await jwtService.initialize(repo);
expect(getJwtSecret(jwtService)).toEqual('env-pinned-secret');
expect(repo.findActiveByType).not.toHaveBeenCalled();
expect(repo.insertOrIgnore).not.toHaveBeenCalled();
});
it('should use the value from the active DB row when one exists', async () => {
const repo = makeRepo();
repo.findActiveByType.mockResolvedValue({ value: 'db-stored-secret' });
const jwtService = new JwtService(instanceSettings, globalConfig);
await jwtService.initialize(repo);
expect(getJwtSecret(jwtService)).toEqual('db-stored-secret');
expect(repo.findActiveByType).toHaveBeenCalledWith('signing.jwt');
expect(repo.insertOrIgnore).not.toHaveBeenCalled();
});
it('should persist the derived jwtSecret when no active DB row exists', async () => {
const repo = makeRepo();
repo.findActiveByType.mockResolvedValue(null);
const jwtService = new JwtService(instanceSettings, globalConfig);
const derivedSecret = getJwtSecret(jwtService);
await jwtService.initialize(repo);
expect(repo.insertOrIgnore).toHaveBeenCalledWith({
type: 'signing.jwt',
value: derivedSecret,
status: 'active',
algorithm: null,
});
expect(getJwtSecret(jwtService)).toEqual(derivedSecret);
});
it('should use the winner row when a concurrent insert is ignored', async () => {
const repo = makeRepo();
repo.findActiveByType
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ value: 'winner-secret' });
repo.insertOrIgnore.mockResolvedValue(undefined);
const jwtService = new JwtService(instanceSettings, globalConfig);
await jwtService.initialize(repo);
expect(getJwtSecret(jwtService)).toEqual('winner-secret');
});
});
});

View file

@ -1,14 +1,16 @@
import { GlobalConfig } from '@n8n/config';
import { Container, Service } from '@n8n/di';
import { Service } from '@n8n/di';
import { createHash } from 'crypto';
import jwt from 'jsonwebtoken';
import { InstanceSettings } from 'n8n-core';
@Service()
export class JwtService {
jwtSecret: string = '';
private jwtSecret: string = '';
private readonly jwtSecretFromEnv: boolean;
constructor({ encryptionKey }: InstanceSettings, globalConfig: GlobalConfig) {
this.jwtSecretFromEnv = Boolean(globalConfig.userManagement.jwtSecret);
this.jwtSecret = globalConfig.userManagement.jwtSecret;
if (!this.jwtSecret) {
// If we don't have a JWT secret set, generate one based on encryption key.
@ -19,10 +21,42 @@ export class JwtService {
baseKey += encryptionKey[i];
}
this.jwtSecret = createHash('sha256').update(baseKey).digest('hex');
Container.get(GlobalConfig).userManagement.jwtSecret = this.jwtSecret;
globalConfig.userManagement.jwtSecret = this.jwtSecret;
}
}
/**
* Two-phase init: reads or creates the signing.jwt deployment-key row.
* Must be called after DB migrations complete, before request handlers register.
* Precedence: N8N_USER_MANAGEMENT_JWT_SECRET env DB active row derive-from-key (and persist)
*/
async initialize(repo: {
findActiveByType(type: string): Promise<{ value: string } | null>;
insertOrIgnore(entity: {
type: string;
value: string;
status: string;
algorithm: null;
}): Promise<void>;
}): Promise<void> {
if (this.jwtSecretFromEnv) {
return;
}
const existing = await repo.findActiveByType('signing.jwt');
if (existing) {
this.jwtSecret = existing.value;
return;
}
await repo.insertOrIgnore({
type: 'signing.jwt',
value: this.jwtSecret,
status: 'active',
algorithm: null,
});
const winner = await repo.findActiveByType('signing.jwt');
if (winner) this.jwtSecret = winner.value;
}
sign(payload: object, options: jwt.SignOptions = {}): string {
return jwt.sign(payload, this.jwtSecret, options);
}

View file

@ -102,4 +102,72 @@ describe('BinaryDataConfig', () => {
);
});
});
describe('initialize()', () => {
const makeRepo = () =>
({
findActiveByType: jest.fn(),
insertOrIgnore: jest.fn().mockResolvedValue(undefined),
}) as {
findActiveByType: jest.Mock;
insertOrIgnore: jest.Mock;
};
afterEach(() => {
delete process.env.N8N_BINARY_DATA_SIGNING_SECRET;
});
it('should return early when N8N_BINARY_DATA_SIGNING_SECRET env var is set', async () => {
process.env.N8N_BINARY_DATA_SIGNING_SECRET = 'env-pinned-secret';
const repo = makeRepo();
const config = Container.get(BinaryDataConfig);
await config.initialize(repo);
expect(repo.findActiveByType).not.toHaveBeenCalled();
expect(repo.insertOrIgnore).not.toHaveBeenCalled();
});
it('should use the value from the active DB row when one exists', async () => {
const repo = makeRepo();
repo.findActiveByType.mockResolvedValue({ value: 'db-stored-secret' });
const config = Container.get(BinaryDataConfig);
await config.initialize(repo);
expect(config.signingSecret).toEqual('db-stored-secret');
expect(repo.findActiveByType).toHaveBeenCalledWith('signing.binary_data');
expect(repo.insertOrIgnore).not.toHaveBeenCalled();
});
it('should persist the derived signing secret when no active DB row exists', async () => {
const repo = makeRepo();
repo.findActiveByType.mockResolvedValue(null);
const config = Container.get(BinaryDataConfig);
const derivedSecret = config.signingSecret;
await config.initialize(repo);
expect(repo.insertOrIgnore).toHaveBeenCalledWith({
type: 'signing.binary_data',
value: derivedSecret,
status: 'active',
algorithm: null,
});
expect(config.signingSecret).toEqual(derivedSecret);
});
it('should use the winner row when a concurrent insert is ignored', async () => {
const repo = makeRepo();
repo.findActiveByType
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ value: 'winner-secret' });
repo.insertOrIgnore.mockResolvedValue(undefined);
const config = Container.get(BinaryDataConfig);
await config.initialize(repo);
expect(config.signingSecret).toEqual('winner-secret');
});
});
});

View file

@ -63,4 +63,36 @@ export class BinaryDataConfig {
this.mode ??= executionsConfig.mode === 'queue' ? 'database' : 'filesystem';
}
/**
* Two-phase init: reads or creates the signing.binary_data deployment-key row.
* Must be called after DB migrations complete, before any signed-URL generation.
* Precedence: N8N_BINARY_DATA_SIGNING_SECRET env DB active row derive-from-key (and persist)
*/
async initialize(repo: {
findActiveByType(type: string): Promise<{ value: string } | null>;
insertOrIgnore(entity: {
type: string;
value: string;
status: string;
algorithm: null;
}): Promise<void>;
}): Promise<void> {
if (process.env.N8N_BINARY_DATA_SIGNING_SECRET) {
return;
}
const existing = await repo.findActiveByType('signing.binary_data');
if (existing) {
this.signingSecret = existing.value;
return;
}
await repo.insertOrIgnore({
type: 'signing.binary_data',
value: this.signingSecret,
status: 'active',
algorithm: null,
});
const winner = await repo.findActiveByType('signing.binary_data');
if (winner) this.signingSecret = winner.value;
}
}

View file

@ -213,6 +213,136 @@ describe('InstanceSettings', () => {
});
});
describe('initialize', () => {
const mockRepo = {
findActiveByType: jest.fn(),
insertOrIgnore: jest.fn(),
};
let settings: InstanceSettings;
beforeEach(() => {
mockFs.existsSync.mockReturnValue(false);
mockFs.mkdirSync.mockReturnValue('');
mockFs.writeFileSync.mockReturnValue();
settings = createInstanceSettings({ encryptionKey: 'test_key' });
// Default: no DB rows, inserts succeed
mockRepo.findActiveByType.mockResolvedValue(null);
mockRepo.insertOrIgnore.mockResolvedValue(undefined);
});
describe('instance.id', () => {
it('should use N8N_INSTANCE_ID env var and skip DB entirely', async () => {
process.env.N8N_INSTANCE_ID = 'env-pinned-id';
await settings.initialize(mockRepo);
expect(settings.instanceId).toEqual('env-pinned-id');
expect(mockRepo.findActiveByType).not.toHaveBeenCalledWith('instance.id');
expect(mockRepo.insertOrIgnore).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'instance.id' }),
);
});
it('should use the value from the active DB row when one exists', async () => {
mockRepo.findActiveByType.mockImplementation(async (type: string) =>
type === 'instance.id' ? { value: 'db-stored-id' } : null,
);
await settings.initialize(mockRepo);
expect(settings.instanceId).toEqual('db-stored-id');
expect(mockRepo.insertOrIgnore).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'instance.id' }),
);
});
it('should persist the derived instanceId when no active DB row exists', async () => {
const derivedId = settings.instanceId;
await settings.initialize(mockRepo);
expect(mockRepo.insertOrIgnore).toHaveBeenCalledWith({
type: 'instance.id',
value: derivedId,
status: 'active',
algorithm: null,
});
expect(settings.instanceId).toEqual(derivedId);
});
it('should use the winner row when a concurrent insert is ignored', async () => {
mockRepo.insertOrIgnore.mockImplementation(async (entity: { type: string }) => {
// Simulate conflict only for instance.id
if (entity.type === 'instance.id') return undefined;
});
mockRepo.findActiveByType.mockImplementation(async (type: string) =>
type === 'instance.id' ? { value: 'winner-id' } : null,
);
await settings.initialize(mockRepo);
expect(settings.instanceId).toEqual('winner-id');
});
});
describe('signing.hmac', () => {
it('should use N8N_HMAC_SIGNATURE_SECRET env var and skip DB entirely', async () => {
process.env.N8N_HMAC_SIGNATURE_SECRET = 'env-pinned-hmac';
await settings.initialize(mockRepo);
expect(settings.hmacSignatureSecret).toEqual('env-pinned-hmac');
expect(mockRepo.findActiveByType).not.toHaveBeenCalledWith('signing.hmac');
expect(mockRepo.insertOrIgnore).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'signing.hmac' }),
);
});
it('should use the value from the active DB row when one exists', async () => {
mockRepo.findActiveByType.mockImplementation(async (type: string) =>
type === 'signing.hmac' ? { value: 'db-stored-hmac' } : null,
);
await settings.initialize(mockRepo);
expect(settings.hmacSignatureSecret).toEqual('db-stored-hmac');
expect(mockRepo.insertOrIgnore).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'signing.hmac' }),
);
});
it('should persist the derived HMAC secret when no active DB row exists', async () => {
const derivedHmac = settings.hmacSignatureSecret;
await settings.initialize(mockRepo);
expect(mockRepo.insertOrIgnore).toHaveBeenCalledWith({
type: 'signing.hmac',
value: derivedHmac,
status: 'active',
algorithm: null,
});
expect(settings.hmacSignatureSecret).toEqual(derivedHmac);
});
it('should use the winner row when a concurrent insert is ignored', async () => {
mockRepo.insertOrIgnore.mockImplementation(async (entity: { type: string }) => {
if (entity.type === 'signing.hmac') return undefined;
});
mockRepo.findActiveByType.mockImplementation(async (type: string) =>
type === 'signing.hmac' ? { value: 'winner-hmac' } : null,
);
await settings.initialize(mockRepo);
expect(settings.hmacSignatureSecret).toEqual('winner-hmac');
});
});
});
describe('isDocker', () => {
it('should return true if /.dockerenv exists', () => {
mockFs.existsSync.mockImplementation((path) => path === '/.dockerenv');

View file

@ -52,13 +52,14 @@ export class InstanceSettings {
/**
* Fixed ID of this n8n instance, for telemetry.
* Derived from encryption key. Do not confuse with `hostId`.
* Derived from encryption key on first boot, then read from DB.
* Do not confuse with `hostId`.
*
* @example '258fce876abf5ea60eb86a2e777e5e190ff8f3e36b5b37aafec6636c31d4d1f9'
*/
readonly instanceId: string;
instanceId: string;
readonly hmacSignatureSecret: string;
hmacSignatureSecret: string;
readonly instanceType: InstanceType;
@ -75,6 +76,73 @@ export class InstanceSettings {
this.hmacSignatureSecret = this.getOrGenerateHmacSignatureSecret();
}
/**
* Two-phase init: reads or creates deployment-key rows for instance.id and signing.hmac.
* Must be called after DB migrations complete, before license init.
*
* Precedence for each key: env var DB active row derive-from-key (and persist).
*
* The repo parameter is typed inline rather than imported from @n8n/db to
* avoid a circular package dependency: @n8n/db depends on n8n-core at runtime.
*/
async initialize(repo: {
findActiveByType(type: string): Promise<{ value: string } | null>;
insertOrIgnore(entity: {
type: string;
value: string;
status: string;
algorithm: null;
}): Promise<void>;
}): Promise<void> {
await this.initSecret(
repo,
'instance.id',
process.env.N8N_INSTANCE_ID,
() => this.instanceId,
(v) => {
this.instanceId = v;
},
);
await this.initSecret(
repo,
'signing.hmac',
process.env.N8N_HMAC_SIGNATURE_SECRET,
() => this.hmacSignatureSecret,
(v) => {
this.hmacSignatureSecret = v;
},
);
}
private async initSecret(
repo: {
findActiveByType(type: string): Promise<{ value: string } | null>;
insertOrIgnore(entity: {
type: string;
value: string;
status: string;
algorithm: null;
}): Promise<void>;
},
type: string,
envValue: string | undefined,
get: () => string,
set: (v: string) => void,
): Promise<void> {
if (envValue) {
set(envValue);
return;
}
const existing = await repo.findActiveByType(type);
if (existing) {
set(existing.value);
return;
}
await repo.insertOrIgnore({ type, value: get(), status: 'active', algorithm: null });
const winner = await repo.findActiveByType(type);
if (winner) set(winner.value);
}
/**
* A main is:
* - `unset` during bootup,