mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
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
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:
parent
c97c3b4d12
commit
bb96d2e50a
14 changed files with 486 additions and 17 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue