mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
feat(engine): Authenticate control plane requests to the engine (#36894)
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Config, Env } from '../decorators';
|
||||
|
||||
/**
|
||||
* Floor for the CP → DP shared secret. Kept in step with the engine's identity
|
||||
* verifier, which rejects anything shorter.
|
||||
*/
|
||||
const AUTH_SECRET_MIN_LENGTH = 32;
|
||||
|
||||
@Config
|
||||
export class EngineConfig {
|
||||
/** Port the engine HTTP server listens on. */
|
||||
@@ -33,4 +41,13 @@ export class EngineConfig {
|
||||
*/
|
||||
@Env('N8N_ENGINE_DATABASE_URL')
|
||||
databaseUrl: string = '';
|
||||
|
||||
/**
|
||||
* Shared secret the control plane signs its identity token with and the engine
|
||||
* verifies against. Both planes must hold the same value.
|
||||
*
|
||||
* This is in development and not ready for use.
|
||||
*/
|
||||
@Env('N8N_ENGINE_AUTH_SECRET', z.string().min(AUTH_SECRET_MIN_LENGTH))
|
||||
authSecret: string = '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
import { EngineConfig } from '../src/index';
|
||||
|
||||
describe('EngineConfig', () => {
|
||||
const originalEnv = process.env;
|
||||
let consoleWarnMock: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
Container.reset();
|
||||
process.env = {};
|
||||
consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
consoleWarnMock.mockRestore();
|
||||
});
|
||||
|
||||
it('should accept an auth secret of at least 32 characters', () => {
|
||||
const secret = 'a'.repeat(32);
|
||||
process.env.N8N_ENGINE_AUTH_SECRET = secret;
|
||||
|
||||
expect(Container.get(EngineConfig).authSecret).toBe(secret);
|
||||
});
|
||||
|
||||
it('should reject a shorter auth secret and fall back to the default', () => {
|
||||
process.env.N8N_ENGINE_AUTH_SECRET = 'a'.repeat(31);
|
||||
|
||||
expect(Container.get(EngineConfig).authSecret).toBe('');
|
||||
expect(consoleWarnMock).toHaveBeenCalledWith(expect.stringContaining('N8N_ENGINE_AUTH_SECRET'));
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@
|
||||
"@n8n/di": "workspace:*",
|
||||
"@n8n/typeorm": "workspace:*",
|
||||
"express": "catalog:",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"pg": "catalog:",
|
||||
"reflect-metadata": "catalog:",
|
||||
"uuid": "catalog:",
|
||||
@@ -38,6 +39,7 @@
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@testcontainers/postgresql": "catalog:",
|
||||
"@types/express": "catalog:",
|
||||
"@types/jsonwebtoken": "catalog:",
|
||||
"@types/pg": "^8.15.6",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"n8n-containers": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import type { MockInstance } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createAuthenticationMiddleware } from '../authenticate';
|
||||
import { mintIdentityToken, SharedSecretIdentityVerifier } from '../identity-token';
|
||||
|
||||
const secret = 'a'.repeat(32);
|
||||
const caller = { cpId: 'cp-1', tenantId: 'tenant-1' };
|
||||
|
||||
const app = () => {
|
||||
const application = express();
|
||||
application.use(createAuthenticationMiddleware(new SharedSecretIdentityVerifier(secret)));
|
||||
application.get('/', (req, res) => {
|
||||
res.status(200).json({ caller: req.caller });
|
||||
});
|
||||
return application;
|
||||
};
|
||||
|
||||
describe('createAuthenticationMiddleware', () => {
|
||||
let consoleWarnMock: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnMock.mockRestore();
|
||||
});
|
||||
|
||||
it('401s when no header is present', async () => {
|
||||
const response = await request(app()).get('/');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('401s on a non-Bearer scheme', async () => {
|
||||
const response = await request(app()).get('/').set('Authorization', 'Basic garbage');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('401s on a garbage Bearer token', async () => {
|
||||
const response = await request(app()).get('/').set('Authorization', 'Bearer garbage');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('the 401 body carries no failure reason', async () => {
|
||||
const response = await request(app()).get('/').set('Authorization', 'Bearer garbage');
|
||||
|
||||
expect(response.body).toEqual({ error: 'unauthenticated' });
|
||||
});
|
||||
|
||||
it('warns on a rejected attempt, naming the route it guarded', async () => {
|
||||
await request(app()).get('/').set('Authorization', 'Bearer garbage');
|
||||
|
||||
expect(consoleWarnMock).toHaveBeenCalledWith('engine: rejected GET / - token rejected');
|
||||
});
|
||||
|
||||
it('warns when no bearer token is present', async () => {
|
||||
await request(app()).get('/');
|
||||
|
||||
expect(consoleWarnMock).toHaveBeenCalledWith('engine: rejected GET / - no bearer token');
|
||||
});
|
||||
|
||||
it('keeps the query string out of the log', async () => {
|
||||
await request(app()).get('/?token=secret-value');
|
||||
|
||||
expect(consoleWarnMock).toHaveBeenCalledWith('engine: rejected GET / - no bearer token');
|
||||
});
|
||||
|
||||
it('200s and populates req.caller for a valid token', async () => {
|
||||
const token = mintIdentityToken(secret, caller);
|
||||
|
||||
const response = await request(app()).get('/').set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ caller });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
IDENTITY_AUDIENCE,
|
||||
IDENTITY_ISSUER,
|
||||
IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS,
|
||||
IDENTITY_TOKEN_TTL_SECONDS,
|
||||
InvalidIdentityTokenError,
|
||||
mintIdentityToken,
|
||||
SharedSecretIdentityVerifier,
|
||||
} from '../identity-token';
|
||||
|
||||
const secret = 'a'.repeat(32);
|
||||
const caller = { cpId: 'cp-1', tenantId: 'tenant-1' };
|
||||
|
||||
/** Past every deadline the verifier allows, so one advance covers expiry and max age. */
|
||||
const PAST_EVERY_DEADLINE_MS =
|
||||
(IDENTITY_TOKEN_TTL_SECONDS + IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS + 1) * 1000;
|
||||
|
||||
/**
|
||||
* Signs a token from raw claims, bypassing {@link mintIdentityToken}. Only for
|
||||
* tokens the control plane cannot mint: a missing `exp`, a foreign audience, an
|
||||
* expiry untied to the TTL. Anything the clock can express moves the clock
|
||||
* instead, so these tests do not restate how a real token is built.
|
||||
*/
|
||||
const signRawToken = (claims: Record<string, unknown>, options: jwt.SignOptions = {}) =>
|
||||
jwt.sign(claims, secret, {
|
||||
algorithm: 'HS256',
|
||||
issuer: IDENTITY_ISSUER,
|
||||
audience: IDENTITY_AUDIENCE,
|
||||
...options,
|
||||
});
|
||||
|
||||
describe('SharedSecretIdentityVerifier', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('constructing rejects an under-length secret', () => {
|
||||
expect(() => new SharedSecretIdentityVerifier('short')).toThrow();
|
||||
});
|
||||
|
||||
it('constructing rejects a missing secret', () => {
|
||||
expect(() => new SharedSecretIdentityVerifier('')).toThrow();
|
||||
});
|
||||
|
||||
it('round trips: verify returns the caller mint signed', () => {
|
||||
const token = mintIdentityToken(secret, caller);
|
||||
const verifier = new SharedSecretIdentityVerifier(secret);
|
||||
|
||||
expect(verifier.verify(token)).toEqual(caller);
|
||||
});
|
||||
|
||||
it('rejects a token signed with a different secret', () => {
|
||||
const token = mintIdentityToken('b'.repeat(32), caller);
|
||||
const verifier = new SharedSecretIdentityVerifier(secret);
|
||||
|
||||
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
|
||||
});
|
||||
|
||||
it('accepts a token still inside its lifetime', () => {
|
||||
const token = mintIdentityToken(secret, caller);
|
||||
const verifier = new SharedSecretIdentityVerifier(secret);
|
||||
|
||||
vi.advanceTimersByTime((IDENTITY_TOKEN_TTL_SECONDS - 1) * 1000);
|
||||
|
||||
expect(verifier.verify(token)).toEqual(caller);
|
||||
});
|
||||
|
||||
it('rejects a token once its lifetime has passed', () => {
|
||||
const token = mintIdentityToken(secret, caller);
|
||||
const verifier = new SharedSecretIdentityVerifier(secret);
|
||||
|
||||
vi.advanceTimersByTime(PAST_EVERY_DEADLINE_MS);
|
||||
|
||||
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
|
||||
});
|
||||
|
||||
it('rejects a token minted further ahead than the clock-skew tolerance', () => {
|
||||
const now = Date.now();
|
||||
const verifier = new SharedSecretIdentityVerifier(secret);
|
||||
|
||||
// Minted on a clock that runs ahead of this host by more than it tolerates.
|
||||
vi.setSystemTime(now + (IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS + 60) * 1000);
|
||||
const token = mintIdentityToken(secret, caller);
|
||||
vi.setSystemTime(now);
|
||||
|
||||
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
|
||||
});
|
||||
|
||||
it('rejects a token older than the maximum age even when it has not expired', () => {
|
||||
// `mintIdentityToken` ties `exp` to the TTL, so only a raw token can outlive it.
|
||||
const token = signRawToken(
|
||||
{ sub: caller.cpId, tenant_id: caller.tenantId },
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
const verifier = new SharedSecretIdentityVerifier(secret);
|
||||
|
||||
vi.advanceTimersByTime(PAST_EVERY_DEADLINE_MS);
|
||||
|
||||
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
|
||||
});
|
||||
|
||||
it('rejects a token without an expiration', () => {
|
||||
const token = signRawToken({ sub: caller.cpId, tenant_id: caller.tenantId });
|
||||
const verifier = new SharedSecretIdentityVerifier(secret);
|
||||
|
||||
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
|
||||
});
|
||||
|
||||
it('rejects a token with the wrong audience', () => {
|
||||
const token = signRawToken(
|
||||
{ sub: caller.cpId, tenant_id: caller.tenantId },
|
||||
{ audience: 'someone-else', expiresIn: IDENTITY_TOKEN_TTL_SECONDS },
|
||||
);
|
||||
const verifier = new SharedSecretIdentityVerifier(secret);
|
||||
|
||||
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
|
||||
});
|
||||
|
||||
it('rejects a token with the wrong issuer', () => {
|
||||
const token = signRawToken(
|
||||
{ sub: caller.cpId, tenant_id: caller.tenantId },
|
||||
{ issuer: 'someone-else', expiresIn: IDENTITY_TOKEN_TTL_SECONDS },
|
||||
);
|
||||
const verifier = new SharedSecretIdentityVerifier(secret);
|
||||
|
||||
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
|
||||
});
|
||||
|
||||
it('rejects an alg: none token', () => {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
sub: caller.cpId,
|
||||
tenant_id: caller.tenantId,
|
||||
iss: IDENTITY_ISSUER,
|
||||
aud: IDENTITY_AUDIENCE,
|
||||
exp: Math.floor(Date.now() / 1000) + IDENTITY_TOKEN_TTL_SECONDS,
|
||||
}),
|
||||
).toString('base64url');
|
||||
const token = `${header}.${payload}.`;
|
||||
const verifier = new SharedSecretIdentityVerifier(secret);
|
||||
|
||||
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
|
||||
});
|
||||
|
||||
it('rejects a token missing tenant_id', () => {
|
||||
const token = signRawToken({ sub: caller.cpId }, { expiresIn: IDENTITY_TOKEN_TTL_SECONDS });
|
||||
const verifier = new SharedSecretIdentityVerifier(secret);
|
||||
|
||||
expect(() => verifier.verify(token)).toThrow(InvalidIdentityTokenError);
|
||||
});
|
||||
|
||||
it('rejects a non-JWT string', () => {
|
||||
const verifier = new SharedSecretIdentityVerifier(secret);
|
||||
|
||||
expect(() => verifier.verify('not-a-jwt')).toThrow(InvalidIdentityTokenError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
import type { IdentityVerifier } from './identity.types';
|
||||
import { fail } from '../server/error-response';
|
||||
|
||||
const BEARER_PREFIX = /^bearer /i;
|
||||
|
||||
/** Verifies `Authorization: Bearer <token>` on every request it guards, or 401s with no reason. */
|
||||
export function createAuthenticationMiddleware(verifier: IdentityVerifier): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
// The reason is logged, never returned: an operator needs it, a caller must not have it.
|
||||
const reject = (reason: string): void => {
|
||||
// Query string dropped: it can carry values that must not reach a log.
|
||||
const path = req.originalUrl.split('?')[0];
|
||||
console.warn(`engine: rejected ${req.method} ${path} - ${reason}`);
|
||||
fail(res, 401, { error: 'unauthenticated' });
|
||||
};
|
||||
|
||||
const header = req.header('authorization');
|
||||
if (!header || !BEARER_PREFIX.test(header)) {
|
||||
reject('no bearer token');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = header.replace(BEARER_PREFIX, '');
|
||||
|
||||
try {
|
||||
req.caller = verifier.verify(token);
|
||||
} catch {
|
||||
reject('token rejected');
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AuthenticatedCaller } from './identity.types';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
/** Set by the authentication middleware once a request clears it. */
|
||||
caller?: AuthenticatedCaller;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { AuthenticatedCaller, IdentityVerifier } from './identity.types';
|
||||
|
||||
export const IDENTITY_ISSUER = 'n8n-cp';
|
||||
export const IDENTITY_AUDIENCE = 'n8n-engine-dp';
|
||||
export const IDENTITY_TOKEN_TTL_SECONDS = 60;
|
||||
export const IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS = 30;
|
||||
export const MIN_SECRET_LENGTH = 32;
|
||||
|
||||
const identityClaimsSchema = z.object({
|
||||
sub: z.string().min(1),
|
||||
tenant_id: z.string().min(1),
|
||||
iat: z.number().int(),
|
||||
exp: z.number().int(),
|
||||
});
|
||||
|
||||
/** Every rejection path throws this one type, so the middleware cannot leak which check failed. */
|
||||
export class InvalidIdentityTokenError extends Error {}
|
||||
|
||||
/** Signs an identity token the engine's {@link SharedSecretIdentityVerifier} accepts. */
|
||||
export function mintIdentityToken(secret: string, caller: AuthenticatedCaller): string {
|
||||
return jwt.sign({ sub: caller.cpId, tenant_id: caller.tenantId }, secret, {
|
||||
algorithm: 'HS256',
|
||||
issuer: IDENTITY_ISSUER,
|
||||
audience: IDENTITY_AUDIENCE,
|
||||
expiresIn: IDENTITY_TOKEN_TTL_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
/** Verifies an identity token against a shared secret. The trust source for CP → DP today. */
|
||||
export class SharedSecretIdentityVerifier implements IdentityVerifier {
|
||||
constructor(private readonly secret: string) {
|
||||
if (!secret || secret.length < MIN_SECRET_LENGTH) {
|
||||
throw new Error(`Identity verifier secret must be at least ${MIN_SECRET_LENGTH} chars`);
|
||||
}
|
||||
}
|
||||
|
||||
verify(token: string): AuthenticatedCaller {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let claims: unknown;
|
||||
try {
|
||||
// `algorithms` is pinned: an unpinned verify accepts whatever `alg` the
|
||||
// token names, including `none`. `clockTolerance` allows for clock skew
|
||||
// between the CP and DP hosts.
|
||||
claims = jwt.verify(token, this.secret, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: IDENTITY_ISSUER,
|
||||
audience: IDENTITY_AUDIENCE,
|
||||
maxAge: IDENTITY_TOKEN_TTL_SECONDS,
|
||||
clockTolerance: IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS,
|
||||
clockTimestamp: now,
|
||||
});
|
||||
} catch {
|
||||
throw new InvalidIdentityTokenError();
|
||||
}
|
||||
|
||||
const parsed = identityClaimsSchema.safeParse(claims);
|
||||
if (!parsed.success) throw new InvalidIdentityTokenError();
|
||||
if (parsed.data.iat > now + IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS) {
|
||||
throw new InvalidIdentityTokenError();
|
||||
}
|
||||
|
||||
return { cpId: parsed.data.sub, tenantId: parsed.data.tenant_id };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** The peer a verified identity token proves. */
|
||||
export interface AuthenticatedCaller {
|
||||
/** Identifies the control plane that signed the token. Carried in the `sub` claim. */
|
||||
cpId: string;
|
||||
/** The tenant the call acts for. Scopes every resource the request may touch. */
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an identity token into the caller it proves. The only seam that knows
|
||||
* the trust source: a shared secret today, an STS JWKS in cloud.
|
||||
*/
|
||||
export interface IdentityVerifier {
|
||||
/**
|
||||
* Returns the caller the token proves, or throws when the token is not
|
||||
* trustworthy. Throws one error type only, so no caller can tell which check
|
||||
* failed.
|
||||
*/
|
||||
verify(token: string): AuthenticatedCaller;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { createAuthenticationMiddleware } from './authenticate';
|
||||
export {
|
||||
IDENTITY_AUDIENCE,
|
||||
IDENTITY_ISSUER,
|
||||
IDENTITY_TOKEN_CLOCK_TOLERANCE_SECONDS,
|
||||
IDENTITY_TOKEN_TTL_SECONDS,
|
||||
InvalidIdentityTokenError,
|
||||
MIN_SECRET_LENGTH,
|
||||
mintIdentityToken,
|
||||
SharedSecretIdentityVerifier,
|
||||
} from './identity-token';
|
||||
export type { AuthenticatedCaller, IdentityVerifier } from './identity.types';
|
||||
@@ -5,6 +5,7 @@ import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AllowAllAdmittance } from '../../admittance';
|
||||
import { mintIdentityToken, SharedSecretIdentityVerifier } from '../../auth';
|
||||
import {
|
||||
createDataSource,
|
||||
createStores,
|
||||
@@ -27,6 +28,12 @@ const graph: WorkflowGraph = {
|
||||
edges: [{ from: 'trigger', to: 'node-a', outputIndex: 0, inputIndex: 0 }],
|
||||
};
|
||||
|
||||
const secret = 'a'.repeat(32);
|
||||
|
||||
const authHeader = () => ({
|
||||
authorization: `Bearer ${mintIdentityToken(secret, { cpId: 'cp-1', tenantId: 'tenant-1' })}`,
|
||||
});
|
||||
|
||||
describe('step execution (integration)', () => {
|
||||
let container: StartedPostgreSqlContainer;
|
||||
let dataSource: DataSource;
|
||||
@@ -59,6 +66,7 @@ describe('step execution (integration)', () => {
|
||||
const runtime = createEngineRuntime({
|
||||
dataSource,
|
||||
admittance: new AllowAllAdmittance(),
|
||||
identityVerifier: new SharedSecretIdentityVerifier(secret),
|
||||
// also how the test reaches the stores the runtime owns
|
||||
externalDependencies: ({ executionStore }) => {
|
||||
const finishExecution = executionStore.finishExecution.bind(executionStore);
|
||||
@@ -74,6 +82,7 @@ describe('step execution (integration)', () => {
|
||||
|
||||
const response = await request(runtime.app)
|
||||
.post('/api/workflow-executions')
|
||||
.set(authHeader())
|
||||
.send({ workflowId, graph: workflowGraph, triggerOutputs })
|
||||
.expect(201);
|
||||
const { executionId } = response.body as StartExecutionResult;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export { createEngineRuntime } from './runtime';
|
||||
export type { EngineRuntime, EngineRuntimeOptions } from './runtime';
|
||||
|
||||
export {
|
||||
InvalidIdentityTokenError,
|
||||
mintIdentityToken,
|
||||
SharedSecretIdentityVerifier,
|
||||
} from './auth';
|
||||
export type { AuthenticatedCaller, IdentityVerifier } from './auth';
|
||||
|
||||
export type { EngineErrorResponse } from './server';
|
||||
|
||||
export type { JsonObject, JsonValue } from './common';
|
||||
|
||||
@@ -3,26 +3,48 @@ import request from 'supertest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AllowAllAdmittance } from '../../admittance';
|
||||
import { mintIdentityToken, SharedSecretIdentityVerifier } from '../../auth';
|
||||
import type { EngineStores } from '../../database';
|
||||
import { createEngineRuntime } from '../create-engine-runtime';
|
||||
|
||||
/** Enough of a `DataSource` for the stores: they only hold on to a repository. */
|
||||
const fakeDataSource = () => ({ getRepository: vi.fn(() => ({})) }) as unknown as DataSource;
|
||||
|
||||
const secret = 'a'.repeat(32);
|
||||
const identityVerifier = new SharedSecretIdentityVerifier(secret);
|
||||
const token = mintIdentityToken(secret, { cpId: 'cp-1', tenantId: 'tenant-1' });
|
||||
|
||||
const runtime = () =>
|
||||
createEngineRuntime({
|
||||
dataSource: fakeDataSource(),
|
||||
admittance: new AllowAllAdmittance(),
|
||||
identityVerifier,
|
||||
});
|
||||
|
||||
describe('createEngineRuntime', () => {
|
||||
it('mounts the execution API', async () => {
|
||||
it('mounts the execution API behind authentication', async () => {
|
||||
const unauthenticated = await request(runtime().app).post('/api/workflow-executions').send({});
|
||||
expect(unauthenticated.status).toBe(401);
|
||||
|
||||
// a rejected body proves the route is mounted without reaching the store
|
||||
const response = await request(runtime().app).post('/api/workflow-executions').send({});
|
||||
const response = await request(runtime().app)
|
||||
.post('/api/workflow-executions')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('does not parse an unauthenticated request body', async () => {
|
||||
const response = await request(runtime().app)
|
||||
.post('/api/workflow-executions')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ error: 'unauthenticated' });
|
||||
});
|
||||
|
||||
it('serves the healthcheck', async () => {
|
||||
const response = await request(runtime().app).get('/healthz');
|
||||
|
||||
@@ -36,6 +58,7 @@ describe('createEngineRuntime', () => {
|
||||
createEngineRuntime({
|
||||
dataSource: fakeDataSource(),
|
||||
admittance: new AllowAllAdmittance(),
|
||||
identityVerifier,
|
||||
externalDependencies: (given) => {
|
||||
stores = given;
|
||||
return {};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DataSource } from '@n8n/typeorm';
|
||||
import type { Application } from 'express';
|
||||
|
||||
import type { AdmittanceService } from '../admittance';
|
||||
import type { IdentityVerifier } from '../auth/identity.types';
|
||||
import { createStores } from '../database';
|
||||
import type { EngineStores } from '../database';
|
||||
import type { ExternalDependencies } from '../dependencies';
|
||||
@@ -21,6 +22,8 @@ export interface EngineRuntimeOptions {
|
||||
/** The data plane database, already initialized and migrated. */
|
||||
dataSource: DataSource;
|
||||
admittance: AdmittanceService;
|
||||
/** Verifies the identity token on every `/api` request. No default: an unauthenticated engine must never boot by omission. */
|
||||
identityVerifier: IdentityVerifier;
|
||||
/**
|
||||
* Builds the capabilities the engine does not own. It receives the engine's
|
||||
* stores, because a `v1-node` executor reads step data through them and the
|
||||
@@ -54,6 +57,7 @@ export interface EngineRuntime {
|
||||
export function createEngineRuntime({
|
||||
dataSource,
|
||||
admittance,
|
||||
identityVerifier,
|
||||
externalDependencies,
|
||||
}: EngineRuntimeOptions): EngineRuntime {
|
||||
const orchestrationQueue = new InMemoryWorkQueue<OrchestrationMessage>();
|
||||
@@ -77,6 +81,7 @@ export function createEngineRuntime({
|
||||
|
||||
const { app } = createEngineServer(
|
||||
new StartExecutionService(admittance, executionStore, orchestrationQueue),
|
||||
identityVerifier,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EngineConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { AllowAllAdmittance } from './admittance';
|
||||
import { SharedSecretIdentityVerifier } from './auth';
|
||||
import { createDataSource } from './database';
|
||||
import { createEngineRuntime } from './runtime';
|
||||
|
||||
@@ -14,11 +15,19 @@ async function main(): Promise<void> {
|
||||
throw new Error('engine: N8N_ENGINE_DATABASE_URL is not set');
|
||||
}
|
||||
|
||||
if (!config.authSecret) {
|
||||
throw new Error('engine: N8N_ENGINE_AUTH_SECRET is not set');
|
||||
}
|
||||
|
||||
const dataSource = createDataSource(config.databaseUrl);
|
||||
await dataSource.initialize();
|
||||
await dataSource.runMigrations();
|
||||
|
||||
const runtime = createEngineRuntime({ dataSource, admittance: new AllowAllAdmittance() });
|
||||
const runtime = createEngineRuntime({
|
||||
dataSource,
|
||||
admittance: new AllowAllAdmittance(),
|
||||
identityVerifier: new SharedSecretIdentityVerifier(config.authSecret),
|
||||
});
|
||||
runtime.start();
|
||||
|
||||
const server = runtime.app.listen(config.port, config.host, () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AllowAllAdmittance } from '../../admittance';
|
||||
import { mintIdentityToken, SharedSecretIdentityVerifier } from '../../auth';
|
||||
import { createDataSource, createStores, WorkflowExecution } from '../../database';
|
||||
import { StartExecutionService } from '../../execution';
|
||||
import type { WorkflowGraph } from '../../graph';
|
||||
@@ -16,6 +17,12 @@ const sampleGraph: WorkflowGraph = {
|
||||
edges: [],
|
||||
};
|
||||
|
||||
const secret = 'a'.repeat(32);
|
||||
|
||||
const authHeader = () => ({
|
||||
authorization: `Bearer ${mintIdentityToken(secret, { cpId: 'cp-1', tenantId: 'tenant-1' })}`,
|
||||
});
|
||||
|
||||
describe('POST /api/workflow-executions (integration)', () => {
|
||||
let container: StartedPostgreSqlContainer;
|
||||
let dataSource: DataSource;
|
||||
@@ -35,6 +42,7 @@ describe('POST /api/workflow-executions (integration)', () => {
|
||||
const { executionStore } = createStores(dataSource);
|
||||
({ url, stop } = await startEngineServer(
|
||||
new StartExecutionService(new AllowAllAdmittance(), executionStore, workQueue),
|
||||
new SharedSecretIdentityVerifier(secret),
|
||||
));
|
||||
});
|
||||
|
||||
@@ -50,6 +58,7 @@ describe('POST /api/workflow-executions (integration)', () => {
|
||||
it('creates an execution row, publishes execution:enqueued, returns 201', async () => {
|
||||
const response = await request(url)
|
||||
.post('/api/workflow-executions')
|
||||
.set(authHeader())
|
||||
.send({
|
||||
workflowId: 'wf-1',
|
||||
graph: sampleGraph,
|
||||
@@ -76,6 +85,7 @@ describe('POST /api/workflow-executions (integration)', () => {
|
||||
it('rejects an invalid body with 400', async () => {
|
||||
const response = await request(url)
|
||||
.post('/api/workflow-executions')
|
||||
.set(authHeader())
|
||||
.send({ workflowId: 'wf-1' }); // missing graph
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -85,6 +95,7 @@ describe('POST /api/workflow-executions (integration)', () => {
|
||||
it('rejects a bare-object triggerOutputs with 400 (the legacy, dropped shape)', async () => {
|
||||
const response = await request(url)
|
||||
.post('/api/workflow-executions')
|
||||
.set(authHeader())
|
||||
.send({
|
||||
workflowId: 'wf-1',
|
||||
graph: sampleGraph,
|
||||
@@ -98,6 +109,7 @@ describe('POST /api/workflow-executions (integration)', () => {
|
||||
it.each([['str'], [42]])('rejects triggerOutputs %p with 400', async (triggerOutputs) => {
|
||||
const response = await request(url)
|
||||
.post('/api/workflow-executions')
|
||||
.set(authHeader())
|
||||
.send({ workflowId: 'wf-1', graph: sampleGraph, triggerOutputs });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -105,7 +117,7 @@ describe('POST /api/workflow-executions (integration)', () => {
|
||||
});
|
||||
|
||||
it('rejects an empty-array triggerOutputs with 400 (send null or omit for "no payload")', async () => {
|
||||
const response = await request(url).post('/api/workflow-executions').send({
|
||||
const response = await request(url).post('/api/workflow-executions').set(authHeader()).send({
|
||||
workflowId: 'wf-1',
|
||||
graph: sampleGraph,
|
||||
triggerOutputs: [],
|
||||
@@ -118,6 +130,7 @@ describe('POST /api/workflow-executions (integration)', () => {
|
||||
it('rejects a triggerOutputs with more slots than the cap with 400', async () => {
|
||||
const response = await request(url)
|
||||
.post('/api/workflow-executions')
|
||||
.set(authHeader())
|
||||
.send({
|
||||
workflowId: 'wf-1',
|
||||
graph: sampleGraph,
|
||||
@@ -131,6 +144,7 @@ describe('POST /api/workflow-executions (integration)', () => {
|
||||
it('rejects a graph without a trigger with 400, creating nothing', async () => {
|
||||
const response = await request(url)
|
||||
.post('/api/workflow-executions')
|
||||
.set(authHeader())
|
||||
.send({
|
||||
workflowId: 'wf-1',
|
||||
graph: { nodes: [{ id: 'a', name: 'A', type: 'v1-node' }], edges: [] },
|
||||
@@ -144,6 +158,7 @@ describe('POST /api/workflow-executions (integration)', () => {
|
||||
it('rejects a graph with back-edges with 501, creating nothing', async () => {
|
||||
const response = await request(url)
|
||||
.post('/api/workflow-executions')
|
||||
.set(authHeader())
|
||||
.send({
|
||||
workflowId: 'wf-1',
|
||||
graph: {
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import express, { type Application } from 'express';
|
||||
|
||||
import { createAuthenticationMiddleware } from '../auth/authenticate';
|
||||
import type { IdentityVerifier } from '../auth/identity.types';
|
||||
import type { StartExecutionService } from '../execution/start-execution.service';
|
||||
import { createWorkflowExecutionsRouter } from './routes/workflow-executions';
|
||||
|
||||
/** Builds the engine HTTP app: `/healthz` plus the execution API. */
|
||||
export function createEngineServer(startExecution: StartExecutionService): { app: Application } {
|
||||
/** Builds the engine HTTP app: `/healthz` plus the authenticated execution API. */
|
||||
export function createEngineServer(
|
||||
startExecution: StartExecutionService,
|
||||
identityVerifier: IdentityVerifier,
|
||||
): { app: Application } {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Stays open: a liveness probe, and it reveals nothing.
|
||||
app.get('/healthz', (_req, res) => {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// Mounted on the prefix, not on each router, so a future router cannot forget it.
|
||||
app.use('/api', createAuthenticationMiddleware(identityVerifier));
|
||||
app.use('/api', express.json());
|
||||
app.use('/api/workflow-executions', createWorkflowExecutionsRouter(startExecution));
|
||||
|
||||
return { app };
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { Response } from 'express';
|
||||
import assert from 'node:assert';
|
||||
|
||||
/**
|
||||
* Body the engine API returns for every non-2xx response.
|
||||
*
|
||||
@@ -11,3 +14,9 @@ export interface EngineErrorResponse {
|
||||
reason?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/** Sends an error response. Shared, so every route and middleware answers in one shape. */
|
||||
export function fail(res: Response, status: number, body: EngineErrorResponse): void {
|
||||
assert(status >= 400, `fail() sends error responses only, but got status ${status}`);
|
||||
res.status(status).json(body);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { createEngineServer } from './create-engine-server';
|
||||
export { fail } from './error-response';
|
||||
export type { EngineErrorResponse } from './error-response';
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Router, type Response, type Router as RouterType } from 'express';
|
||||
import assert from 'node:assert';
|
||||
import { Router, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AdmittanceRejectedError } from '../../admittance';
|
||||
import { UnimplementedError, type JsonValue } from '../../common';
|
||||
import type { StartExecutionService } from '../../execution/start-execution.service';
|
||||
import { GraphValidationError, MAX_SLOT_INDEX } from '../../graph';
|
||||
import type { EngineErrorResponse } from '../error-response';
|
||||
import { fail } from '../error-response';
|
||||
|
||||
const MAX_TRIGGER_SLOTS = MAX_SLOT_INDEX + 1;
|
||||
|
||||
@@ -56,11 +55,6 @@ const StartExecutionBody = z.object({
|
||||
export function createWorkflowExecutionsRouter(startExecution: StartExecutionService): RouterType {
|
||||
const router = Router();
|
||||
|
||||
const fail = (res: Response, status: number, body: EngineErrorResponse): void => {
|
||||
assert(status >= 400, `fail() sends error responses only, but got status ${status}`);
|
||||
res.status(status).json(body);
|
||||
};
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const parsed = StartExecutionBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { SharedSecretIdentityVerifier } from '../../auth';
|
||||
import type { StartExecutionService } from '../../execution';
|
||||
import { startEngineServer } from '../start-engine-server';
|
||||
|
||||
@@ -10,7 +11,10 @@ describe('engine HTTP server (e2e)', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
// only /healthz is under test, and the execution route never calls the service
|
||||
({ url, stop } = await startEngineServer({} as StartExecutionService));
|
||||
({ url, stop } = await startEngineServer(
|
||||
{} as StartExecutionService,
|
||||
new SharedSecretIdentityVerifier('a'.repeat(32)),
|
||||
));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import type { IdentityVerifier } from '../auth/identity.types';
|
||||
import type { StartExecutionService } from '../execution';
|
||||
import { createEngineServer } from '../server';
|
||||
|
||||
export async function startEngineServer(startExecution: StartExecutionService): Promise<{
|
||||
export async function startEngineServer(
|
||||
startExecution: StartExecutionService,
|
||||
identityVerifier: IdentityVerifier,
|
||||
): Promise<{
|
||||
url: string;
|
||||
stop: () => Promise<void>;
|
||||
}> {
|
||||
const { app } = createEngineServer(startExecution);
|
||||
const { app } = createEngineServer(startExecution, identityVerifier);
|
||||
|
||||
const server = await new Promise<Server>((resolve, reject) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
import {
|
||||
AllowAllAdmittance,
|
||||
createEngineRuntime,
|
||||
mintIdentityToken,
|
||||
SharedSecretIdentityVerifier,
|
||||
WorkflowExecution,
|
||||
WorkflowStepExecution,
|
||||
} from '@n8n/engine';
|
||||
@@ -48,6 +50,9 @@ export const realNodeTypes: INodeTypes = {
|
||||
|
||||
export const converter = new V1WorkflowConverter();
|
||||
|
||||
const authSecret = 'a'.repeat(32);
|
||||
const caller = { cpId: 'cp-1', tenantId: 'tenant-1' };
|
||||
|
||||
export type Assignment = { name: string; value: string | number; type: string };
|
||||
|
||||
/** A Set node definition applying `assignments`, wired by the caller. */
|
||||
@@ -97,6 +102,7 @@ export function makeRunWorkflow(getDataSource: () => EngineDataSource) {
|
||||
const runtime = createEngineRuntime({
|
||||
dataSource,
|
||||
admittance: new AllowAllAdmittance(),
|
||||
identityVerifier: new SharedSecretIdentityVerifier(authSecret),
|
||||
// also how the test reaches the stores the runtime owns
|
||||
externalDependencies: ({ executionStore, stepStore }) => {
|
||||
const finishExecution = executionStore.finishExecution.bind(executionStore);
|
||||
@@ -120,6 +126,7 @@ export function makeRunWorkflow(getDataSource: () => EngineDataSource) {
|
||||
// over HTTP, because that is the engine's only boundary
|
||||
const response = await request(runtime.app)
|
||||
.post('/api/workflow-executions')
|
||||
.set('Authorization', `Bearer ${mintIdentityToken(authSecret, caller)}`)
|
||||
.send({ workflowId: 'wf-m1', graph, triggerOutputs })
|
||||
.expect(201);
|
||||
const { executionId } = response.body as StartExecutionResult;
|
||||
|
||||
@@ -4,12 +4,16 @@ import type {
|
||||
OutboundHttp,
|
||||
} from '@n8n/backend-network';
|
||||
import type { EngineConfig } from '@n8n/config';
|
||||
import { SharedSecretIdentityVerifier } from '@n8n/engine';
|
||||
import type { StartExecutionRequest } from '@n8n/engine';
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
import { OperationalError, UserError } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { EngineDataPlaneClient } from '../engine-data-plane-client';
|
||||
|
||||
const authSecret = 'a'.repeat(32);
|
||||
|
||||
describe('EngineDataPlaneClient', () => {
|
||||
const request: StartExecutionRequest = {
|
||||
workflowId: 'wf-1',
|
||||
@@ -42,8 +46,9 @@ describe('EngineDataPlaneClient', () => {
|
||||
});
|
||||
|
||||
return new EngineDataPlaneClient(
|
||||
mock<EngineConfig>({ port: 3000, host: '0.0.0.0', baseUrl: '', ...config }),
|
||||
mock<EngineConfig>({ port: 3000, host: '0.0.0.0', baseUrl: '', authSecret, ...config }),
|
||||
outboundHttp,
|
||||
mock<InstanceSettings>({ instanceId: 'instance-1' }),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -66,6 +71,16 @@ describe('EngineDataPlaneClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not follow redirects, so the identity token reaches only the configured host', async () => {
|
||||
respondWith(201, { executionId: 'exec-1' });
|
||||
|
||||
await client.startExecution(request);
|
||||
|
||||
expect(http.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ disableFollowRedirect: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('dials the loopback, not the bind host', () => {
|
||||
expect(clientOptions?.baseURL).toBe('http://127.0.0.1:3000');
|
||||
});
|
||||
@@ -86,6 +101,19 @@ describe('EngineDataPlaneClient', () => {
|
||||
expect(clientOptions?.ssrf).toBe('disabled');
|
||||
});
|
||||
|
||||
it('sends an identity token the engine accepts, proving the caller is the instance id', () => {
|
||||
const headers = clientOptions?.headers;
|
||||
const resolved = typeof headers === 'function' ? headers() : headers;
|
||||
const authorization = resolved?.authorization ?? '';
|
||||
|
||||
expect(authorization).toMatch(/^Bearer .+/);
|
||||
|
||||
const token = authorization.replace('Bearer ', '');
|
||||
const verifier = new SharedSecretIdentityVerifier(authSecret);
|
||||
|
||||
expect(verifier.verify(token)).toEqual({ cpId: 'instance-1', tenantId: 'instance-1' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
case: 'a rejected graph',
|
||||
@@ -122,6 +150,13 @@ describe('EngineDataPlaneClient', () => {
|
||||
errorClass: OperationalError,
|
||||
message: 'Engine responded with 500: boom',
|
||||
},
|
||||
{
|
||||
case: 'a redirect the client refused to follow',
|
||||
statusCode: 302,
|
||||
body: '',
|
||||
errorClass: OperationalError,
|
||||
message: 'Engine responded with 302',
|
||||
},
|
||||
{
|
||||
case: 'a body that is not the engine error shape',
|
||||
statusCode: 502,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { ExecutionsConfig } from '@n8n/config';
|
||||
import { EngineConfig, ExecutionsConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service';
|
||||
@@ -11,6 +11,7 @@ import { EngineV2Runtime } from '../engine-v2.runtime';
|
||||
describe('EngineV2Module', () => {
|
||||
let module: EngineV2Module;
|
||||
let executionsConfig: ExecutionsConfig;
|
||||
let engineConfig: EngineConfig;
|
||||
let runtime: EngineV2Runtime;
|
||||
let client: EngineDataPlaneClient;
|
||||
|
||||
@@ -18,6 +19,7 @@ describe('EngineV2Module', () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
executionsConfig = mockInstance(ExecutionsConfig, { mode: 'regular' });
|
||||
engineConfig = mockInstance(EngineConfig, { authSecret: '' });
|
||||
runtime = mockInstance(EngineV2Runtime);
|
||||
client = mockInstance(EngineDataPlaneClient);
|
||||
Container.set(EngineDataPlaneProxyService, new EngineDataPlaneProxyService());
|
||||
@@ -49,6 +51,20 @@ describe('EngineV2Module', () => {
|
||||
|
||||
expect(client.startExecution).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
it('generates a secret when unset', async () => {
|
||||
await module.init();
|
||||
|
||||
expect(engineConfig.authSecret).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('leaves a configured secret untouched', async () => {
|
||||
engineConfig.authSecret = 'a-configured-secret';
|
||||
|
||||
await module.init();
|
||||
|
||||
expect(engineConfig.authSecret).toBe('a-configured-secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown', () => {
|
||||
|
||||
@@ -66,6 +66,7 @@ const mocks = vi.hoisted(() => {
|
||||
|
||||
vi.mock('@n8n/engine', () => ({
|
||||
AllowAllAdmittance: vi.fn(),
|
||||
SharedSecretIdentityVerifier: vi.fn(),
|
||||
createDataSource: mocks.createDataSource,
|
||||
createEngineRuntime: mocks.createEngineRuntime,
|
||||
}));
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { HttpRequestClient } from '@n8n/backend-network';
|
||||
import { OutboundHttp } from '@n8n/backend-network';
|
||||
import { EngineConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { EngineErrorResponse, StartExecutionRequest, StartExecutionResult } from '@n8n/engine';
|
||||
import type {
|
||||
AuthenticatedCaller,
|
||||
EngineErrorResponse,
|
||||
StartExecutionRequest,
|
||||
StartExecutionResult,
|
||||
} from '@n8n/engine';
|
||||
import { mintIdentityToken } from '@n8n/engine';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import { OperationalError, UserError } from 'n8n-workflow';
|
||||
|
||||
import type { EngineDataPlaneProvider } from '@/services/engine-data-plane-proxy.service';
|
||||
@@ -20,7 +27,11 @@ import type { EngineDataPlaneProvider } from '@/services/engine-data-plane-proxy
|
||||
export class EngineDataPlaneClient implements EngineDataPlaneProvider {
|
||||
private readonly http: HttpRequestClient;
|
||||
|
||||
constructor(engineConfig: EngineConfig, outboundHttp: OutboundHttp) {
|
||||
constructor(
|
||||
private readonly engineConfig: EngineConfig,
|
||||
outboundHttp: OutboundHttp,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
) {
|
||||
this.http = outboundHttp.requests({
|
||||
// Fixed, n8n-controlled host.
|
||||
ssrf: 'disabled',
|
||||
@@ -28,9 +39,24 @@ export class EngineDataPlaneClient implements EngineDataPlaneProvider {
|
||||
// dialable. Default to loopback and let `N8N_ENGINE_BASE_URL` override
|
||||
// when the engine answers somewhere else.
|
||||
baseURL: engineConfig.baseUrl || `http://127.0.0.1:${engineConfig.port}`,
|
||||
// A factory, not a fixed value: every request gets a fresh short-lived
|
||||
// token, and `engineConfig.authSecret` is read at request time — after
|
||||
// the module generates it, which is after this constructor runs.
|
||||
headers: () => ({
|
||||
authorization: `Bearer ${mintIdentityToken(this.engineConfig.authSecret, this.caller())}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* In a single-tenant deployment the CP is the tenant; cloud replaces
|
||||
* `tenantId` with a real one.
|
||||
*/
|
||||
private caller(): AuthenticatedCaller {
|
||||
const { instanceId } = this.instanceSettings;
|
||||
return { cpId: instanceId, tenantId: instanceId };
|
||||
}
|
||||
|
||||
async startExecution(request: StartExecutionRequest): Promise<StartExecutionResult> {
|
||||
const response = await this.http.request<StartExecutionResult | EngineErrorResponse>({
|
||||
url: '/api/workflow-executions',
|
||||
@@ -41,9 +67,16 @@ export class EngineDataPlaneClient implements EngineDataPlaneProvider {
|
||||
// Inspect the status here so engine failures map onto n8n error types
|
||||
// instead of surfacing as a generic request error.
|
||||
ignoreHttpStatusErrors: true,
|
||||
// The identity token must reach the configured data plane and nowhere
|
||||
// else. Following a redirect would forward it to whatever host the
|
||||
// response names.
|
||||
disableFollowRedirect: true,
|
||||
});
|
||||
|
||||
if (response.statusCode >= 400) throw this.toError(response.statusCode, response.body);
|
||||
// 3xx included: redirects are not followed, so a redirecting target is a
|
||||
// misconfiguration, not a hop. Treating it as success would parse the
|
||||
// redirect body as an execution result.
|
||||
if (response.statusCode >= 300) throw this.toError(response.statusCode, response.body);
|
||||
|
||||
return response.body as StartExecutionResult;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ExecutionsConfig } from '@n8n/config';
|
||||
import { EngineConfig, ExecutionsConfig } from '@n8n/config';
|
||||
import type { ModuleInterface } from '@n8n/decorators';
|
||||
import { BackendModule, OnShutdown } from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Runs the engine 2.0 data plane in-process.
|
||||
@@ -21,6 +22,13 @@ export class EngineV2Module implements ModuleInterface {
|
||||
throw new UserError('The engine-v2 module does not support queue mode.');
|
||||
}
|
||||
|
||||
const engineConfig = Container.get(EngineConfig);
|
||||
// Both planes live in this process, so a generated secret is enough and the
|
||||
// integrated engine is never unauthenticated. A separate CP must set it.
|
||||
if (!engineConfig.authSecret) {
|
||||
engineConfig.authSecret = randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
const { EngineV2Runtime } = await import('./engine-v2.runtime.js');
|
||||
await Container.get(EngineV2Runtime).init();
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Logger } from '@n8n/backend-common';
|
||||
import { EngineConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { EngineRuntime } from '@n8n/engine';
|
||||
import { AllowAllAdmittance, createDataSource, createEngineRuntime } from '@n8n/engine';
|
||||
import {
|
||||
AllowAllAdmittance,
|
||||
createDataSource,
|
||||
createEngineRuntime,
|
||||
SharedSecretIdentityVerifier,
|
||||
} from '@n8n/engine';
|
||||
import { createEngineStepDataLoader, V1StepExecutor } from '@n8n/node-engine-compatibility';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
import assert from 'node:assert';
|
||||
@@ -76,6 +81,7 @@ export class EngineV2Runtime {
|
||||
// TODO(CAT-2909): placeholder policy — every execution is admitted and no
|
||||
// limits are applied.
|
||||
admittance: new AllowAllAdmittance(),
|
||||
identityVerifier: new SharedSecretIdentityVerifier(this.engineConfig.authSecret),
|
||||
externalDependencies: ({ executionStore, stepStore }) => ({
|
||||
v1StepExecutor: new V1StepExecutor({
|
||||
nodeTypes: this.nodeTypes,
|
||||
|
||||
Generated
+6
@@ -2289,6 +2289,9 @@ importers:
|
||||
express:
|
||||
specifier: 'catalog:'
|
||||
version: 5.1.0
|
||||
jsonwebtoken:
|
||||
specifier: 'catalog:'
|
||||
version: 9.0.3
|
||||
pg:
|
||||
specifier: 8.21.0
|
||||
version: 8.21.0(pg-native@3.8.0)
|
||||
@@ -2314,6 +2317,9 @@ importers:
|
||||
'@types/express':
|
||||
specifier: 'catalog:'
|
||||
version: 5.0.1
|
||||
'@types/jsonwebtoken':
|
||||
specifier: 'catalog:'
|
||||
version: 9.0.10
|
||||
'@types/pg':
|
||||
specifier: ^8.15.6
|
||||
version: 8.20.0
|
||||
|
||||
Reference in New Issue
Block a user