Merge remote-tracking branch 'origin/cat-4471-add-safe-internal-regex' into cat-4471-migrate-safe-regex-callsites

This commit is contained in:
Emilia 2026-10-06 14:37:52 +01:00
commit 8576b2705c
No known key found for this signature in database
GPG Key ID: 837988349B71DD1A
207 changed files with 4474 additions and 1712 deletions

View File

@ -989,14 +989,6 @@
"hash": "26b2999a3856"
}
],
"packages/extensions/insights/oxlint.config.mts": [
{
"rule": "lint-config-layering",
"line": 9,
"message": "packages/extensions/insights turns 'typescript/no-deprecated' down to 'off' for the whole package.",
"hash": "f936bed8fa8d"
}
],
"packages/frontend/editor-ui/eslint.config.mjs": [
{
"rule": "lint-config-layering",

View File

@ -58,6 +58,15 @@ describe('matchGlob', () => {
);
});
it('backend module pattern matches packaged module files', () => {
assert.ok(
matchGlob(
'packages/modules/insights/backend/src/insights.service.ts',
'packages/modules/*/backend/**',
),
);
});
it('* matches single-level only', () => {
assert.ok(matchGlob('README.md', '*.md'));
assert.ok(!matchGlob('docs/README.md', '*.md'));

File diff suppressed because one or more lines are too long

View File

@ -178,6 +178,7 @@ jobs:
packages/core/src/execution-engine/eval-mock-helpers.ts
.github/workflows/test-evals-discovery.yml
db:
packages/modules/*/backend/**
packages/cli/src/databases/**
packages/cli/src/modules/*/database/**
packages/cli/src/modules/**/*.entity.ts
@ -185,8 +186,7 @@ jobs:
packages/cli/test/integration/**
packages/cli/test/migration/**
packages/cli/test/shared/db/**
packages/cli/test/setup-testcontainers.js
packages/cli/test/teardown-testcontainers.js
packages/cli/test/setup-testcontainers.ts
packages/@n8n/db/**
packages/@n8n/backend-test-utils/**
packages/@n8n/scheduler/src/**
@ -195,6 +195,7 @@ jobs:
packages/cli/vitest.*
packages/quality/environments/containers/services/postgres.ts
packages/quality/environments/containers/services/redis.ts
packages/quality/environments/containers/test-db-global-setup.ts
packages/quality/environments/containers/postgres-versions.json
packages/quality/environments/containers/test-containers.ts
.github/workflows/test-db-reusable.yml
@ -209,8 +210,7 @@ jobs:
packages/@n8n/backend-test-utils/**
packages/cli/test/migration/**
packages/cli/test/shared/db/**
packages/cli/test/setup-testcontainers.js
packages/cli/test/teardown-testcontainers.js
packages/cli/test/setup-testcontainers.ts
packages/cli/vitest.config.base.ts
packages/cli/vitest.config.migration.ts
packages/cli/vitest.config.migration.testcontainers.ts

View File

@ -55,6 +55,13 @@ jobs:
working-directory: packages/cli
run: ${{ matrix.test-cmd }}
# A packaged backend module opts in by defining `test:postgres`.
- name: Run packaged backend module PostgreSQL tests
if: matrix.TEST_IMAGE_POSTGRES != ''
env:
COVERAGE_ENABLED: 'false'
run: pnpm --filter './packages/modules/*/backend' --if-present run test:postgres --maxWorkers=100%
# Regenerates the schema docs for this leg's database (sqlite temp file /
# postgres testcontainer) and fails if they differ from what's committed.
# tbls runs as a Docker image in CI.

19
OWNERS
View File

@ -109,7 +109,7 @@ packages/cli/src/environments.ee/ @n8n-io/ligo
packages/cli/src/public-api/ @n8n-io/ligo
packages/cli/src/modules/source-control.ee/ @n8n-io/ligo
packages/cli/src/modules/external-secrets.ee/ @n8n-io/ligo
packages/cli/src/modules/insights/ @n8n-io/ligo
packages/modules/insights/ @n8n-io/ligo
packages/cli/src/collaboration/ @n8n-io/catalysts
packages/cli/src/binary-data/ @n8n-io/catalysts
@ -119,7 +119,7 @@ packages/cli/src/modules/data-table/ @n8n-io/adore
packages/cli/src/evaluation.ee/ @n8n-io/ai
packages/cli/src/chat/ @n8n-io/ai
packages/cli/src/tool-generation/ @n8n-io/ai
packages/cli/src/modules/workflow-builder/ @n8n-io/ai
packages/cli/src/modules/workflow-builder/ @n8n-io/ai-assistant
packages/cli/src/modules/mcp/ @n8n-io/adore
packages/cli/src/modules/quick-connect/ @n8n-io/ai
packages/cli/src/modules/chat-hub/ @n8n-io/ai-assistant
@ -140,7 +140,7 @@ packages/cli/src/controllers/user-settings.controller.ts @n8n-io/iam
packages/cli/src/controllers/api-keys.controller.ts @n8n-io/iam
packages/cli/src/controllers/security-settings.controller.ts @n8n-io/iam
packages/cli/src/controllers/oauth/ @n8n-io/iam
packages/cli/src/controllers/ai.controller.ts @n8n-io/ai
packages/cli/src/controllers/ai.controller.ts @n8n-io/ai-assistant
packages/cli/src/controllers/annotation-tags.controller.ee.ts @n8n-io/ai
packages/cli/src/controllers/cta.controller.ts @n8n-io/adore
packages/cli/src/controllers/folder.controller.ts @n8n-io/adore
@ -162,9 +162,9 @@ packages/cli/src/services/public-api-key.service.ts @n8n-io/iam
packages/cli/src/services/security-settings.service.ts @n8n-io/iam
packages/cli/src/services/static-auth-service.ts @n8n-io/iam
packages/cli/src/services/access.service.ts @n8n-io/iam
packages/cli/src/services/ai.service.ts @n8n-io/ai
packages/cli/src/services/ai-usage.service.ts @n8n-io/ai
packages/cli/src/services/ai-workflow-builder.service.ts @n8n-io/ai
packages/cli/src/services/ai.service.ts @n8n-io/ai-assistant
packages/cli/src/services/ai-usage.service.ts @n8n-io/ai-assistant
packages/cli/src/services/ai-workflow-builder.service.ts @n8n-io/ai-assistant
packages/cli/src/services/annotation-tag.service.ee.ts @n8n-io/ai
packages/cli/src/services/folder.service.ts @n8n-io/adore
packages/cli/src/services/tag.service.ts @n8n-io/adore
@ -182,6 +182,7 @@ packages/cli/src/services/dynamic-node-parameters.service.ts @n8n-io/nodes
packages/frontend/editor-ui/ @n8n-io/frontend
packages/frontend/editor-ui/src/features/ai/ @n8n-io/ai
packages/frontend/editor-ui/src/features/ai/assistant/ @n8n-io/ai-assistant
packages/frontend/editor-ui/src/features/ai/chatHub/ @n8n-io/ai-assistant
packages/frontend/editor-ui/src/features/credentials/ @n8n-io/iam
packages/frontend/editor-ui/src/features/execution/ @n8n-io/ligo
@ -201,7 +202,7 @@ packages/@n8n/instance-ai/ @n8n-io/ai-assistant
packages/@n8n/nodes-langchain/ @n8n-io/ai
packages/@n8n/ai-utilities/ @n8n-io/ai
packages/@n8n/ai-node-sdk/ @n8n-io/ai
packages/@n8n/ai-workflow-builder.ee/ @n8n-io/ai
packages/@n8n/ai-workflow-builder.ee/ @n8n-io/ai-assistant
packages/@n8n/agents/ @n8n-io/ai
packages/frontend/@n8n/chat/ @n8n-io/ai
@ -250,10 +251,6 @@ packages/@n8n/permissions/ @n8n-io/iam
packages/@n8n/client-oauth2/ @n8n-io/iam
packages/@n8n/inbound-auth/ @n8n-io/iam
# LiGo
packages/extensions/insights/ @n8n-io/ligo
# Governance & Observability
packages/modules/type-availability-policies/ @n8n-io/governance-and-observability

View File

@ -56,4 +56,4 @@ export type ApiKeyList = {
owners: ApiKeyOwnerSummary[];
};
export type ApiKeyAudience = 'public-api' | 'mcp-server-api';
export type ApiKeyAudience = 'public-api' | 'mcp-server-api' | 'scim-api';

View File

@ -40,12 +40,12 @@ export const LLM_JUDGE_PROVIDERS: LlmJudgeProvider[] = [
},
{
nodeType: '@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
displayName: 'Azure AI Foundry Chat Model',
displayName: 'Microsoft Foundry Chat Model',
credentialTypes: [
{ name: 'azureOpenAiApi', displayName: 'Azure OpenAI' },
{ name: 'azureOpenAiApi', displayName: 'Microsoft Foundry (API Key)' },
{
name: 'azureEntraCognitiveServicesOAuth2Api',
displayName: 'Azure Entra (Cognitive Services)',
displayName: 'Microsoft Foundry (Entra ID)',
},
],
},

View File

@ -526,7 +526,9 @@ export {
workflowVersionListItemPublicSchema,
} from './workflow-history/workflow-version-history-public.dto';
export {
DeprecatedWorkflowVersionPublicDto,
WorkflowVersionPublicDto,
deprecatedWorkflowVersionPublicSchema,
workflowVersionPublicSchema,
} from './workflow-history/workflow-version-public.dto';
@ -733,3 +735,5 @@ export {
promotePackageResultSchema,
promotionGitResultSchema,
} from './promotions/promotion-operations.dto';
export { ScimConfigPatchDto } from './scim';

View File

@ -0,0 +1 @@
export { ScimConfigPatchDto } from './scim-config.dto';

View File

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
export class ScimConfigPatchDto extends Z.class({
enabled: z.boolean(),
}) {}

View File

@ -7,3 +7,15 @@ export const workflowVersionPublicSchema = activeWorkflowVersionPublicSchema.omi
});
export class WorkflowVersionPublicDto extends Z.class(workflowVersionPublicSchema.shape) {}
export const deprecatedWorkflowVersionPublicSchema = workflowVersionPublicSchema.partial({
nodeGroups: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
});
export class DeprecatedWorkflowVersionPublicDto extends Z.class(
deprecatedWorkflowVersionPublicSchema.shape,
) {}

View File

@ -371,6 +371,14 @@ export type FrontendModuleSettings = {
rotationEnabled: boolean;
};
/**
* Client settings for SCIM user-provisioning module.
*/
scim?: {
/** Whether SCIM provisioning is enabled in the instance. */
scimEnabled: boolean;
};
/**
* Client settings for Chat module.
*/

View File

@ -104,6 +104,7 @@ describe('eligibleModules', () => {
'workflow-reviews',
'instance-ai',
'agents',
'scim',
]);
});
@ -143,6 +144,7 @@ describe('eligibleModules', () => {
'workflow-reviews',
'instance-ai',
'agents',
'scim',
'type-availability-policies',
]);
});

View File

@ -83,6 +83,7 @@ export class ModuleRegistry {
'workflow-reviews',
'instance-ai',
'agents',
'scim',
];
private readonly activeModules: string[] = [];

View File

@ -41,6 +41,7 @@ export const MODULE_NAMES = [
'type-availability-policies',
'instance-reporting',
'inbound-auth-core',
'scim',
] as const;
export type ModuleName = (typeof MODULE_NAMES)[number];

View File

@ -12,6 +12,7 @@ export * from './uuid';
export const LICENSE_FEATURES = {
SHARING: 'feat:sharing',
LDAP: 'feat:ldap',
SCIM: 'feat:scim',
TYPE_AVAILABILITY_POLICIES: 'feat:typeAvailabilityPolicies',
SAML: 'feat:saml',
OIDC: 'feat:oidc',

View File

@ -1,10 +1,14 @@
import { Service } from '@n8n/di';
import { DataSource, In, LessThan } from '@n8n/typeorm';
import { DataSource, In, IsNull, LessThan } from '@n8n/typeorm';
import { DiffMetaData, DiffRule, groupWorkflows, SKIP_RULES } from 'n8n-workflow';
import { WorkflowHistory, WorkflowEntity, WorkflowPublishedVersion } from '../entities';
import {
WorkflowHistory,
WorkflowEntity,
WorkflowPublishedVersion,
WorkflowPublishHistory,
} from '../entities';
import { BaseRepository } from './base-repository';
import { WorkflowPublishHistoryRepository } from './workflow-publish-history.repository';
import { WorkflowReviewRequestWorkflow } from '../entities/workflow-review-request-workflow.ee';
import { WorkflowReviewRequest } from '../entities/workflow-review-request.ee';
import type { OperationContext } from '../services/transaction';
@ -12,11 +16,7 @@ import { TransactionRunner } from '../services/transaction';
@Service()
export class WorkflowHistoryRepository extends BaseRepository<WorkflowHistory> {
constructor(
dataSource: DataSource,
private readonly workflowPublishHistoryRepository: WorkflowPublishHistoryRepository,
transactionRunner: TransactionRunner,
) {
constructor(dataSource: DataSource, transactionRunner: TransactionRunner) {
super(WorkflowHistory, dataSource.manager, transactionRunner);
}
@ -150,8 +150,21 @@ export class WorkflowHistoryRepository extends BaseRepository<WorkflowHistory> {
skipRules: DiffRule[] = [],
metaData?: Partial<Record<keyof DiffMetaData, boolean>>,
): Promise<{ seen: number; deleted: number }> {
const workflows = await this.manager
const publishedVersionSubquery = this.manager
.createQueryBuilder()
.subQuery()
.select('1')
.from(WorkflowPublishHistory, 'wph')
.where('wph.workflowId = wh.workflowId')
.andWhere('wph.versionId = wh.versionId')
.getQuery();
const { entities: workflows, raw } = await this.manager
.createQueryBuilder(WorkflowHistory, 'wh')
.leftJoin(WorkflowEntity, 'w', 'w.id = wh.workflowId')
.addSelect(
`CASE WHEN w.versionId = wh.versionId OR EXISTS ${publishedVersionSubquery} THEN 1 ELSE 0 END`,
'isProtected',
)
.where('wh.workflowId = :workflowId', { workflowId })
.andWhere('wh.createdAt <= :endDate', {
endDate,
@ -160,26 +173,29 @@ export class WorkflowHistoryRepository extends BaseRepository<WorkflowHistory> {
startDate,
})
.orderBy('wh.createdAt', 'ASC')
.getMany();
.addOrderBy('wh.versionId', 'ASC')
.getRawAndEntities<{ wh_versionId: string; isProtected: number }>();
// Group by workflowId
const publishedVersions =
await this.workflowPublishHistoryRepository.getPublishedVersions(workflowId);
// The current version and every version that was ever published stay.
const protectedVersions = new Set(
raw.filter((row) => Number(row.isProtected) === 1).map((row) => row.wh_versionId),
);
const grouped = groupWorkflows<WorkflowHistory>(
workflows,
rules,
[
this.makeSkipActiveAndNamedVersionsRule(
new Set(publishedVersions.map((v) => v.versionId).filter((v) => v !== null)),
),
this.makeSkipActiveAndNamedVersionsRule(protectedVersions),
SKIP_RULES.skipDifferentUsers,
...skipRules,
],
metaData,
);
// A version named after the read above stays, like one named before it.
const { affected } = await this.delete({
versionId: In(grouped.removed.map((x) => x.versionId)),
name: IsNull(),
description: IsNull(),
});
return { seen: workflows.length, deleted: affected ?? grouped.removed.length };
}

View File

@ -38,17 +38,6 @@ export class WorkflowPublishHistoryRepository extends Repository<WorkflowPublish
return await repository.find({ where: { workflowId, versionId }, order: { id: 'ASC' } });
}
async getPublishedVersions(
workflowId: string,
): Promise<Array<Pick<WorkflowPublishHistory, 'versionId'>>> {
return await this.manager
.createQueryBuilder(WorkflowPublishHistory, 'wph')
.select('wph.versionId')
.distinct(true)
.where('wph.workflowId = :workflowId', { workflowId })
.getMany();
}
async findActivatedByUserId(workflowId: string): Promise<string | undefined> {
const record = await this.findOne({
select: ['userId'],

View File

@ -17,6 +17,7 @@ export const ENFORCEMENT_POINTS = [
'workflowStart',
'workflowTransfer',
'credentialSave',
'credentialTransfer',
'credentialDecrypt',
'contentImport',
] as const;
@ -76,7 +77,7 @@ export type WorkflowStartContext = {
export type WorkflowTransferContext = {
readonly workflow: PolicedWorkflow;
/** The project the workflow is moving *into* — that's whose policy applies. */
readonly targetProjectId: string | null;
readonly targetProjectId: string;
};
/**
@ -105,6 +106,11 @@ export type CredentialSaveContext = {
readonly projectId: string | null;
};
export type CredentialTransferContext = {
readonly credential: PolicedCredential & { readonly id: string };
readonly targetProjectId: string;
};
export type CredentialDecryptContext = {
readonly credentialType: string;
readonly credentialId: string;
@ -217,6 +223,10 @@ export interface RegisteredPolicyCheck {
signal: AbortSignal,
): Promise<PolicyCheckResult>;
onCredentialSave?(ctx: CredentialSaveContext, signal: AbortSignal): Promise<PolicyCheckResult>;
onCredentialTransfer?(
ctx: CredentialTransferContext,
signal: AbortSignal,
): Promise<PolicyCheckResult>;
onCredentialDecrypt?(
ctx: CredentialDecryptContext,
signal: AbortSignal,
@ -242,6 +252,7 @@ export const ENFORCEMENT_POINT_METHODS: {
workflowStart: 'onWorkflowStart',
workflowTransfer: 'onWorkflowTransfer',
credentialSave: 'onCredentialSave',
credentialTransfer: 'onCredentialTransfer',
credentialDecrypt: 'onCredentialDecrypt',
contentImport: 'onContentImport',
};

View File

@ -28,11 +28,11 @@ the next `format:check` in CI fails on a module nobody touched by hand.
## Backend
**A placeholder. Nothing loads it.** The backend runtime discovers modules
under `packages/cli/src/modules/<name>`, which is where every real backend
module lives; `packages/modules/<name>/backend` reserves the path for the day
the two halves sit together and gets a README saying so. To create a backend
module that runs, use `pnpm setup-backend-module`.
Backend modules can run from a built workspace package at
`packages/modules/<name>/backend`. Register the package with a lazy import in
`packages/cli/src/modules/modules.manifest.ts`. The scaffolder does not create
this package shape yet. Use `@n8n/backend-module-insights` as the current
example.
## No build step

View File

@ -6,7 +6,7 @@ export class AzureEntraCognitiveServicesOAuth2Api implements ICredentialType {
name = 'azureEntraCognitiveServicesOAuth2Api';
// eslint-disable-next-line n8n-nodes-base/cred-class-field-display-name-missing-oauth2
displayName = 'Azure Entra ID (Azure Active Directory) API';
displayName = 'Microsoft Foundry (Entra ID)';
extends = ['oAuth2Api'];
@ -26,11 +26,11 @@ export class AzureEntraCognitiveServicesOAuth2Api implements ICredentialType {
type: 'options',
options: [
{ name: 'Classic', value: 'classic' },
{ name: 'Azure AI Foundry', value: 'foundry' },
{ name: 'Microsoft Foundry', value: 'foundry' },
],
default: 'classic',
description:
'Classic targets *.openai.azure.com (resource name + deployment-based URLs). Azure AI Foundry targets *.services.ai.azure.com/openai/v1 (full endpoint URL).',
'Classic targets *.openai.azure.com (resource name + deployment-based URLs). Microsoft Foundry targets *.services.ai.azure.com/openai/v1 (full endpoint URL).',
},
{
displayName: 'Resource Name',
@ -56,7 +56,7 @@ export class AzureEntraCognitiveServicesOAuth2Api implements ICredentialType {
default: '',
placeholder: 'https://<resource>.services.ai.azure.com/openai/v1',
displayOptions: { show: { endpointType: ['foundry'] } },
hint: 'The full Azure AI Foundry OpenAI-compatible base URL.',
hint: 'The full Microsoft Foundry OpenAI-compatible base URL.',
},
{
displayName: 'Endpoint',

View File

@ -3,7 +3,7 @@ import type { IAuthenticateGeneric, ICredentialType, INodeProperties } from 'n8n
export class AzureOpenAiApi implements ICredentialType {
name = 'azureOpenAiApi';
displayName = 'Azure Open AI';
displayName = 'Microsoft Foundry (API Key)';
documentationUrl = 'azureopenai';
@ -14,11 +14,11 @@ export class AzureOpenAiApi implements ICredentialType {
type: 'options',
options: [
{ name: 'Classic', value: 'classic' },
{ name: 'Azure AI Foundry', value: 'foundry' },
{ name: 'Microsoft Foundry', value: 'foundry' },
],
default: 'classic',
description:
'Classic targets *.openai.azure.com (resource name + deployment-based URLs). Azure AI Foundry targets *.services.ai.azure.com/openai/v1 (full endpoint URL).',
'Classic targets *.openai.azure.com (resource name + deployment-based URLs). Microsoft Foundry targets *.services.ai.azure.com/openai/v1 (full endpoint URL).',
},
{
displayName: 'API Key',
@ -52,7 +52,7 @@ export class AzureOpenAiApi implements ICredentialType {
default: '',
placeholder: 'https://<resource>.services.ai.azure.com/openai/v1',
displayOptions: { show: { endpointType: ['foundry'] } },
hint: 'The full Azure AI Foundry OpenAI-compatible base URL.',
hint: 'The full Microsoft Foundry OpenAI-compatible base URL.',
},
{
displayName: 'Endpoint',

View File

@ -16,7 +16,7 @@ import {
export class EmbeddingsAzureOpenAi implements INodeType {
description: INodeTypeDescription = {
displayName: 'Embeddings Azure OpenAI',
displayName: 'Microsoft Foundry Embeddings',
name: 'embeddingsAzureOpenAi',
icon: 'file:azure.svg',
credentials: [
@ -27,9 +27,9 @@ export class EmbeddingsAzureOpenAi implements INodeType {
],
group: ['transform'],
version: 1,
description: 'Use Embeddings Azure OpenAI',
description: 'Use Microsoft Foundry Embeddings',
defaults: {
name: 'Embeddings Azure OpenAI',
name: 'Microsoft Foundry Embeddings',
},
codex: {
@ -44,6 +44,15 @@ export class EmbeddingsAzureOpenAi implements INodeType {
},
],
},
// The old labels. Fuzzy search needs them verbatim to find the renamed node.
alias: [
'Azure',
'Azure OpenAI',
'Embeddings Azure OpenAI',
'Azure AI Foundry',
'Foundry',
'AOAI',
],
},
inputs: [],
@ -153,7 +162,7 @@ export class EmbeddingsAzureOpenAi implements INodeType {
if (!foundryURL) {
throw new NodeOperationError(
this.getNode(),
'Foundry endpoint is missing in the selected Azure OpenAI API credential.',
'Foundry endpoint is missing in the selected Microsoft Foundry (API Key) credential.',
);
}
const embeddings = new OpenAIEmbeddings({

View File

@ -21,13 +21,38 @@ vi.mock('@n8n/ai-utilities', async () => {
const MockedAzureOpenAIEmbeddings = vi.mocked(AzureOpenAIEmbeddings);
describe('EmbeddingsAzureOpenAi node identity', () => {
const { description } = new EmbeddingsAzureOpenAi();
it('should be labelled Microsoft Foundry Embeddings', () => {
expect(description.displayName).toBe('Microsoft Foundry Embeddings');
expect(description.defaults.name).toBe('Microsoft Foundry Embeddings');
});
// A saved workflow resolves its nodes by type, so the rename is only safe while this is untouched.
it('should keep the node type, which saved workflows resolve by', () => {
expect(description.name).toBe('embeddingsAzureOpenAi');
});
it.each([
'Azure',
'Azure OpenAI',
'Embeddings Azure OpenAI',
'Azure AI Foundry',
'Foundry',
'AOAI',
])('should be findable by %s', (term) => {
expect(description.codex?.alias).toContain(term);
});
});
describe('AzureOpenAIEmbeddings', () => {
let embeddingsAzureOpenAi: EmbeddingsAzureOpenAi;
let mockContext: Mocked<ISupplyDataFunctions>;
const mockNode: INode = {
id: '1',
name: 'Embeddings Azure OpenAI',
name: 'Microsoft Foundry Embeddings',
typeVersion: 1,
type: '@n8n/n8n-nodes-langchain.embeddingsAzureOpenAi',
position: [0, 0],
@ -148,7 +173,7 @@ describe('AzureOpenAIEmbeddings', () => {
});
await expect(embeddingsAzureOpenAi.supplyData.call(mockContext, 0)).rejects.toThrow(
'Foundry endpoint is missing in the selected Azure OpenAI API credential.',
'Foundry endpoint is missing in the selected Microsoft Foundry (API Key) credential.',
);
});
});

View File

@ -37,7 +37,7 @@ export class LmChatAzureOpenAi implements INodeType {
};
description: INodeTypeDescription = {
displayName: 'Azure AI Foundry Chat Model',
displayName: 'Microsoft Foundry Chat Model',
name: 'lmChatAzureOpenAi',
icon: 'file:azure.svg',
@ -46,7 +46,7 @@ export class LmChatAzureOpenAi implements INodeType {
defaultVersion: 1.1,
description: 'For advanced usage with an AI chain',
defaults: {
name: 'Azure AI Foundry Chat Model',
name: 'Microsoft Foundry Chat Model',
},
codex: {
categories: ['AI'],
@ -63,7 +63,14 @@ export class LmChatAzureOpenAi implements INodeType {
},
// The old label, in full and in part. Fuzzy search matches a pattern into a target, so
// the full former name finds nothing unless it is here verbatim.
alias: ['Azure OpenAI Chat Model', 'Azure OpenAI', 'Azure AI Foundry', 'Foundry'],
alias: [
'Azure',
'Azure OpenAI',
'Azure OpenAI Chat Model',
'Azure AI Foundry',
'Foundry',
'AOAI',
],
},
inputs: [],
@ -163,11 +170,11 @@ export class LmChatAzureOpenAi implements INodeType {
if (!modelConfig.azureFoundryBaseURL) {
throw new NodeOperationError(
this.getNode(),
'Claude deployments need a credential using the Azure AI Foundry endpoint type',
'Claude deployments need a credential using the Microsoft Foundry endpoint type',
{
itemIndex,
description:
'This credential uses the classic endpoint type, which serves only the Azure OpenAI route. Switch the credential to Azure AI Foundry, or set Model Family to OpenAI.',
'This credential uses the classic endpoint type, which serves only the Azure OpenAI route. Switch the credential to Microsoft Foundry, or set Model Family to OpenAI.',
},
);
}
@ -223,7 +230,9 @@ export class LmChatAzureOpenAi implements INodeType {
),
});
this.logger.info(`Azure AI Foundry (Anthropic) client initialized for model: ${modelName}`);
this.logger.info(
`Microsoft Foundry (Anthropic) client initialized for model: ${modelName}`,
);
return { response: model };
}
@ -282,11 +291,11 @@ export class LmChatAzureOpenAi implements INodeType {
if (responsesApiEnabled) {
throw new NodeOperationError(
this.getNode(),
'The Responses API needs a credential using the Azure AI Foundry endpoint type',
'The Responses API needs a credential using the Microsoft Foundry endpoint type',
{
itemIndex,
description:
"This credential uses the classic endpoint type, which addresses a deployment directly and has no Responses API. Switch the credential to Azure AI Foundry, or turn off 'Use Responses API'.",
"This credential uses the classic endpoint type, which addresses a deployment directly and has no Responses API. Switch the credential to Microsoft Foundry, or turn off 'Use Responses API'.",
},
);
}

View File

@ -70,8 +70,8 @@ describe('LmChatAzureOpenAi', () => {
const { description } = new LmChatAzureOpenAi();
it('should be labelled for the whole Foundry catalogue, not just OpenAI', () => {
expect(description.displayName).toBe('Azure AI Foundry Chat Model');
expect(description.defaults.name).toBe('Azure AI Foundry Chat Model');
expect(description.displayName).toBe('Microsoft Foundry Chat Model');
expect(description.defaults.name).toBe('Microsoft Foundry Chat Model');
});
// A saved workflow resolves its nodes by type, so the rename is only safe while this is
@ -86,7 +86,7 @@ describe('LmChatAzureOpenAi', () => {
expect(description.codex?.alias).toContain('Azure OpenAI');
});
it.each(['Azure OpenAI Chat Model', 'Azure AI Foundry', 'Foundry'])(
it.each(['Azure', 'Azure OpenAI Chat Model', 'Azure AI Foundry', 'Foundry', 'AOAI'])(
'should be findable by %s',
(term) => {
expect(description.codex?.alias).toContain(term);
@ -278,7 +278,7 @@ describe('LmChatAzureOpenAi', () => {
const ctx = setupMockContext('azureOpenAiApi', apiKeyCredential, {}, true);
await expect(new LmChatAzureOpenAi().supplyData.call(ctx, 0)).rejects.toThrow(
'The Responses API needs a credential using the Azure AI Foundry endpoint type',
'The Responses API needs a credential using the Microsoft Foundry endpoint type',
);
expect(vi.mocked(AzureChatOpenAI)).not.toHaveBeenCalled();
});
@ -500,7 +500,7 @@ describe('LmChatAzureOpenAi', () => {
const ctx = setupMockContext('azureOpenAiApi', apiKeyCredential, {}, false, 'anthropic');
await expect(new LmChatAzureOpenAi().supplyData.call(ctx, 0)).rejects.toThrow(
'Claude deployments need a credential using the Azure AI Foundry endpoint type',
'Claude deployments need a credential using the Microsoft Foundry endpoint type',
);
expect(vi.mocked(ChatAnthropic)).not.toHaveBeenCalled();
expect(vi.mocked(AzureChatOpenAI)).not.toHaveBeenCalled();

View File

@ -91,7 +91,9 @@ describe('makeAzureFoundryFailedAttemptHandler', () => {
it('should tell a classic credential to switch endpoint type, not to flip the toggle', () => {
const handler = makeAzureFoundryFailedAttemptHandler('gpt-4o', false, 'classic');
expect(() => handler({ status: 404 })).toThrow('Switch the credential to the Azure AI Foundry');
expect(() => handler({ status: 404 })).toThrow(
'Switch the credential to the Microsoft Foundry',
);
expect(() => handler({ status: 404 })).not.toThrow("Turn on 'Use Responses API'");
});

View File

@ -25,7 +25,7 @@ export async function setupApiKeyAuthentication(
if (!configCredentials.apiKey) {
throw new NodeOperationError(
this.getNode(),
'API Key is missing in the selected Azure OpenAI API credential. Please configure the API Key or choose Entra ID authentication.',
'API Key is missing in the selected Microsoft Foundry (API Key) credential. Please configure the API Key or choose Entra ID authentication.',
);
}

View File

@ -12,7 +12,7 @@ export function requireFoundryEndpoint(node: INode, foundryEndpoint: string | un
if (!endpoint) {
throw new NodeOperationError(
node,
'Foundry endpoint is missing in the selected Azure OpenAI credential.',
'Foundry endpoint is missing in the selected Microsoft Foundry credential.',
);
}
return endpoint;

View File

@ -23,7 +23,7 @@ export function requireTenantId(node: INode, tenantId: string | undefined): stri
if (!value) {
throw new NodeOperationError(
node,
'Tenant ID is missing in the selected Azure Entra credential',
'Tenant ID is missing in the selected Microsoft Foundry (Entra ID) credential',
);
}

View File

@ -74,7 +74,7 @@ export function makeAzureFoundryFailedAttemptHandler(
route === 'anthropic'
? 'Set Model Family to OpenAI if the deployment is not a Claude model.'
: route === 'classic'
? 'Switch the credential to the Azure AI Foundry endpoint type if the deployment serves only the Responses API.'
? 'Switch the credential to the Microsoft Foundry endpoint type if the deployment serves only the Responses API.'
: useResponsesApi
? "Turn off 'Use Responses API' if the deployment serves Chat Completions."
: "Turn on 'Use Responses API' if the deployment serves only the Responses API.";

View File

@ -60,7 +60,7 @@ describe('LmChatAzureOpenAi -> searchModels', () => {
});
await expect(searchModels.call(ctx)).rejects.toThrow(
'Only an Azure AI Foundry credential can list deployments',
'Only a Microsoft Foundry credential can list deployments',
);
expect(listAzureOpenAiModelsSpy).not.toHaveBeenCalled();
@ -74,7 +74,7 @@ describe('LmChatAzureOpenAi -> searchModels', () => {
ctx.getCredentials = vi.fn().mockResolvedValue({ resourceName: 'my-resource' });
await expect(searchModels.call(ctx)).rejects.toThrow(
'Only an Azure AI Foundry credential can list deployments',
'Only a Microsoft Foundry credential can list deployments',
);
expect(listAzureOpenAiModelsSpy).not.toHaveBeenCalled();

View File

@ -27,7 +27,7 @@ function resolveFoundryBaseURL(node: INode, foundryEndpoint?: string): string {
function classicCredentialError(node: INode): NodeOperationError {
return new NodeOperationError(
node,
"Only an Azure AI Foundry credential can list deployments. Select By ID and enter the deployment name, or set the credential's Endpoint Type to Azure AI Foundry.",
"Only a Microsoft Foundry credential can list deployments. Select By ID and enter the deployment name, or set the credential's Endpoint Type to Microsoft Foundry.",
);
}

View File

@ -54,7 +54,7 @@ export const properties: INodeProperties[] = [
name: 'project',
type: 'string',
description:
'The Azure AI Foundry project that owns the deployment. Required for an Azure AI Foundry resource; leave empty for a classic Azure OpenAI resource.',
'The Microsoft Foundry project that owns the deployment. Required for a Microsoft Foundry resource; leave empty for a classic Azure OpenAI resource.',
default: '',
},
{
@ -65,7 +65,7 @@ export const properties: INodeProperties[] = [
required: true,
builderHint: {
propertyHint:
'Set the project parameter before you list deployments. The list needs an Azure AI Foundry credential. With a classic credential, use the id mode and enter the deployment name.',
'Set the project parameter before you list deployments. The list needs a Microsoft Foundry credential. With a classic credential, use the id mode and enter the deployment name.',
},
modes: [
{
@ -86,7 +86,7 @@ export const properties: INodeProperties[] = [
},
],
description:
'The deployment to use. Choose from the list (Azure AI Foundry credentials only), or enter the deployment name.',
'The deployment to use. Choose from the list (Microsoft Foundry credentials only), or enter the deployment name.',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.1 } }],
@ -111,7 +111,7 @@ export const properties: INodeProperties[] = [
type: 'boolean',
default: false,
description:
'Whether to call the deployment on the Responses API instead of Chat Completions. Azure does not tell us which one a deployment supports, so set this to match your deployment: leave it off for a chat-completions deployment, turn it on for a Responses-only one. Needs a credential using the Azure AI Foundry endpoint type.',
'Whether to call the deployment on the Responses API instead of Chat Completions. Azure does not tell us which one a deployment supports, so set this to match your deployment: leave it off for a chat-completions deployment, turn it on for a Responses-only one. Needs a credential using the Microsoft Foundry endpoint type.',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.1 } }],

View File

@ -42,7 +42,7 @@ export interface AzureOpenAIBaseModelConfig {
azureOpenAIApiVersion: string;
azureOpenAIEndpoint?: string;
/**
* Full OpenAI-compatible base URL for Azure AI Foundry
* Full OpenAI-compatible base URL for Microsoft Foundry
* (`*.services.ai.azure.com/openai/v1`). When set, the node uses ChatOpenAI
* against this URL instead of AzureChatOpenAI's deployment-based path.
*/

View File

@ -146,6 +146,7 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = `
"insights:read",
"oidc:manage",
"provisioning:manage",
"scim:manage",
"dataTable:create",
"dataTable:read",
"dataTable:update",

View File

@ -77,6 +77,7 @@ export const RESOURCES = {
insights: ['list', 'read'] as const,
oidc: ['manage'] as const,
provisioning: ['manage'] as const,
scim: ['manage'] as const,
dataTable: [
...DEFAULT_OPERATIONS,
'readRow',

View File

@ -58,6 +58,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'orchestration:read',
'orchestration:manage',
'saml:manage',
'scim:manage',
'securityAudit:generate',
'securitySettings:manage',
'sourceControl:pull',

View File

@ -57,6 +57,7 @@ describe('permissions', () => {
instanceAi: {},
roleMappingRule: {},
otel: {},
scim: {},
});
});
it('getResourcePermissions', () => {
@ -191,6 +192,7 @@ describe('permissions', () => {
instanceAi: {},
roleMappingRule: {},
otel: {},
scim: {},
};
expect(getResourcePermissions(scopes)).toEqual(permissionRecord);

View File

@ -11618,9 +11618,9 @@
"key": "@n8n/n8n-nodes-langchain.lmChatAzureOpenAi",
"subcategory": "*",
"properties": {
"displayName": "Azure AI Foundry Chat Model",
"displayName": "Microsoft Foundry Chat Model",
"defaults": {
"name": "Azure AI Foundry Chat Model"
"name": "Microsoft Foundry Chat Model"
},
"description": "For advanced usage with an AI chain",
"name": "@n8n/n8n-nodes-langchain.lmChatAzureOpenAi",
@ -11640,7 +11640,14 @@
}
]
},
"alias": ["Azure OpenAI Chat Model", "Azure OpenAI", "Azure AI Foundry", "Foundry"]
"alias": [
"Azure",
"Azure OpenAI",
"Azure OpenAI Chat Model",
"Azure AI Foundry",
"Foundry",
"AOAI"
]
}
},
"type": "node"

View File

@ -38,7 +38,7 @@ describe('sublimeSearch', () => {
);
});
// The Azure node is labelled 'Azure AI Foundry Chat Model' but was called 'Azure OpenAI Chat
// The Azure node is labelled 'Microsoft Foundry Chat Model' but was called 'Azure OpenAI Chat
// Model' until ENT-470. Its codex alias is what keeps the old name working, so search for the
// node rather than for the string to prove the rename costs no one their muscle memory.
describe('the renamed Azure node stays findable', () => {

View File

@ -163,7 +163,6 @@ export default defineConfig(
files: [
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',
'./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts',
'./src/public-api/v1/handlers/workflows/workflows.handler.ts',
],
rules: {
'n8n-local-rules/require-public-api-controller': 'off',

View File

@ -157,7 +157,6 @@ export default defineConfig({
'./src/public-api/v1/handlers/evaluations/evaluations.handler.ts',
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',
'./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts',
'./src/public-api/v1/handlers/workflows/workflows.handler.ts',
],
rules: {
'n8n-local-rules/require-public-api-controller': 'off',
@ -345,7 +344,6 @@ export default defineConfig({
'./src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts',
'./src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts',
'./src/modules/favorites/favorites.service.ts',
'./src/modules/insights/insights-collection.service.ts',
'./src/modules/instance-ai/instance-ai.adapter.service.ts',
'./src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts',
'./src/modules/instance-ai/storage/typeorm-agent-checkpoint-store.ts',

View File

@ -158,6 +158,7 @@
"@n8n/ai-workflow-builder": "workspace:*",
"@n8n/api-types": "workspace:*",
"@n8n/backend-common": "workspace:*",
"@n8n/backend-module-insights": "workspace:*",
"@n8n/backend-network": "workspace:*",
"@n8n/backend-services": "workspace:*",
"@n8n/blob-storage": "workspace:*",

View File

@ -146,6 +146,7 @@ export class E2EController {
[LICENSE_FEATURES.WORKFLOW_REVIEWS]: false,
[LICENSE_FEATURES.OTEL_CUSTOM_SPAN_ATTRIBUTES]: false,
[LICENSE_FEATURES.WORKER_POOLS]: false,
[LICENSE_FEATURES.SCIM]: false,
};
private static readonly numericFeaturesDefaults: Record<NumericLicenseFeature, number> = {

View File

@ -11,8 +11,9 @@ import { Tracing, type InstanceSettings } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import type { License } from '@/license';
import { InsightsConfig } from '@/modules/insights/insights.config';
import type { InsightsService } from '@/modules/insights/insights.service';
import type { InsightsService } from '@n8n/backend-module-insights';
import { InsightsConfig } from '@n8n/backend-module-insights/config';
import { packagedModules } from '@/modules/modules.manifest';
import { DurableJobProvisioner } from '@/scheduling/durable-job-provisioner';
import { buildMaterializerTransaction } from '@/scheduling/durable-scheduler';
import { SystemTaskHandler } from '@/scheduling/system-tasks/system-task-handler';
@ -38,7 +39,7 @@ describe('instance reporting across mains', () => {
let schedulers: Array<Scheduler & SchedulerPasses> = [];
beforeAll(async () => {
await testModules.loadModules(['insights', 'instance-reporting']);
await testModules.loadModules(['insights', 'instance-reporting'], packagedModules);
await testDb.init();
reports = Container.get(InstanceMonitoringReportRepository);
jobs = Container.get(ScheduledJobRepository);

View File

@ -3,7 +3,7 @@ import type { Settings, SettingsRepository } from '@n8n/db';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { InsightsConfig } from '@/modules/insights/insights.config';
import { InsightsConfig } from '@n8n/backend-module-insights/config';
import { InstanceReportingSettingsService } from '../instance-reporting-settings.service';
import { CENTRAL_INSTANCE_MONITORING_SETTINGS_KEY } from '../instance-reporting.constants';

View File

@ -15,9 +15,9 @@ import { InstanceSettings } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import type { License } from '@/license';
import { createCompactedInsightsEvent } from '@/modules/insights/database/entities/__tests__/db-utils';
import { InsightsConfig } from '@/modules/insights/insights.config';
import { InsightsService } from '@/modules/insights/insights.service';
import { createCompactedInsightsEvent } from '@n8n/backend-module-insights/testing';
import { InsightsConfig, InsightsService } from '@n8n/backend-module-insights';
import { packagedModules } from '@/modules/modules.manifest';
import type {
InstanceMonitoringReport,
@ -71,7 +71,7 @@ describe('instance reporting retries', () => {
let repository: InstanceMonitoringReportRepository;
beforeAll(async () => {
await testModules.loadModules(['insights', 'instance-reporting']);
await testModules.loadModules(['insights', 'instance-reporting'], packagedModules);
await testDb.init();
repository = Container.get(InstanceMonitoringReportRepository);

View File

@ -13,8 +13,8 @@ import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { License } from '@/license';
import { InsightsConfig } from '@/modules/insights/insights.config';
import type { InsightsService } from '@/modules/insights/insights.service';
import type { InsightsService } from '@n8n/backend-module-insights';
import { InsightsConfig } from '@n8n/backend-module-insights/config';
import type { InstanceMonitoringReport } from '../database/entities/instance-monitoring-report';
import type { InstanceMonitoringReportRepository } from '../database/repositories/instance-monitoring-report.repository';

View File

@ -4,7 +4,7 @@ import { Service } from '@n8n/di';
import { randomInt } from 'node:crypto';
import { jsonParse } from 'n8n-workflow';
import { InsightsConfig } from '@/modules/insights/insights.config';
import { InsightsConfig } from '@n8n/backend-module-insights/config';
import { CENTRAL_INSTANCE_MONITORING_SETTINGS_KEY } from './instance-reporting.constants';
/** `HH:mm`, 24-hour. ISO 8601's local-time form; this instance reads it as UTC. */

View File

@ -11,8 +11,7 @@ import { OperationalError } from 'n8n-workflow';
import { N8N_VERSION } from '@/constants';
import { License } from '@/license';
import { InsightsConfig } from '@/modules/insights/insights.config';
import { InsightsService } from '@/modules/insights/insights.service';
import { InsightsConfig, InsightsService } from '@n8n/backend-module-insights';
import type {
InstanceMonitoringReport,

View File

@ -4,4 +4,6 @@ import type { PackagedModules } from '@n8n/backend-common';
* Backend modules that load from workspace packages instead of this directory.
* Keep each import in a thunk so an ineligible module is not imported.
*/
export const packagedModules: PackagedModules = {};
export const packagedModules: PackagedModules = {
insights: async () => await import('@n8n/backend-module-insights/module'),
};

View File

@ -8,7 +8,7 @@ The module holds no policy of its own. A policy feature adds a check class and a
store for its rules. It does not add an enforcement path, an error shape, or an
audit gap.
Why these seven points, why every check must pass, and why a check that does not
Why these eight points, why every check must pass, and why a check that does not
answer blocks: read the policy infrastructure RFC in Notion. This README is the
working reference for writing a check. It does not restate the RFC.
@ -25,7 +25,7 @@ flowchart LR
save["workflowSave<br/>WorkflowCreationService, WorkflowService,<br/>chat hub, instance AI, public API"]
publish["workflowPublish<br/>WorkflowService.activateWorkflow,<br/>WorkflowPublicationApplier, ActiveWorkflowManager"]
start["workflowStart<br/>PolicyLifecycleHandler on<br/>workflowExecuteBefore"]
other["workflowTransfer<br/>credentialSave<br/>contentImport<br/>credentialDecrypt"]
other["workflowTransfer<br/>credentialSave<br/>credentialTransfer<br/>contentImport<br/>credentialDecrypt"]
end
subgraph pep["Enforcement point · src/policy (always loaded)"]
@ -97,6 +97,7 @@ request. A check can compare it with `workflow` to judge only what the save adds
| `workflowStart` | 250 ms | row id | `workflowExecuteBefore` on main, workers, sub-executions, manual runs |
| `workflowTransfer` | 1000 ms | row id | move to another project |
| `credentialSave` | 1000 ms | row id, or type hash for a create | editor, public API, package import stubs, provider connections |
| `credentialTransfer` | 1000 ms | row id | move to another project |
| `contentImport` | 1000 ms | row id | CLI import, source control import, package and git-connection import |
| `credentialDecrypt` | 250 ms | credential id | credential resolution during a run or a test |
@ -125,6 +126,7 @@ Each point hands its check a different context. The types are in
| `workflowStart` | `WorkflowStartContext` | `workflow`, `projectId` |
| `workflowTransfer` | `WorkflowTransferContext` | `workflow`, `targetProjectId` — the project it moves _into_, whose policy applies |
| `credentialSave` | `CredentialSaveContext` | `credential`, `storedCredential` (`null` for a create), `projectId` |
| `credentialTransfer` | `CredentialTransferContext` | `credential`, `targetProjectId` — the project it moves _into_, whose policy applies |
| `contentImport` | `ContentImportContext` | `workflow`, `projectId`, `transport` |
| `credentialDecrypt` | `CredentialDecryptContext` | `credentialType`, `credentialId`, `consumer` (`null` for a credential test), `projectId` |

View File

@ -3,6 +3,7 @@ import { mockLogger } from '@n8n/backend-test-utils';
import {
type CredentialDecryptContext,
type CredentialSaveContext,
type CredentialTransferContext,
type PolicyCheckClass,
type PolicyCheckMetadata,
type PolicyCheckResult,
@ -98,6 +99,11 @@ const credentialUpdateContext: CredentialSaveContext = {
projectId: 'proj-1',
};
const credentialTransferContext: CredentialTransferContext = {
credential: { id: 'cred-1', type: 'slackApi' },
targetProjectId: 'proj-2',
};
const decryptContext: CredentialDecryptContext = {
credentialType: 'slackApi',
credentialId: 'cred-1',
@ -186,6 +192,10 @@ class OtherPointsCheck implements RegisteredPolicyCheck {
async onCredentialSave(): Promise<PolicyCheckResult> {
return { violations: [slackBlocked] };
}
async onCredentialTransfer(): Promise<PolicyCheckResult> {
return { violations: [slackBlocked] };
}
}
/** Only implements `onWorkflowStart`, so it must not be called at other points. */
@ -462,6 +472,19 @@ describe('PolicyDecisionService', () => {
expect(JSON.stringify(audit.mock.calls[0][1])).not.toContain('payload-cred');
});
it('records the project a credential transfer moves into', async () => {
const { service, audit } = auditedServiceWith(OtherPointsCheck);
await service.enforce('credentialTransfer', credentialTransferContext, unattended);
expect(audit.mock.calls[0][1]).toMatchObject({
credentialId: 'cred-1',
credentialType: 'slackApi',
projectId: 'proj-2',
});
expect(audit.mock.calls[0][1]).not.toHaveProperty('workflowId');
});
it('records the credential and the node asking for it', async () => {
const { service, audit } = auditedServiceWith(OtherPointsCheck);

View File

@ -103,6 +103,14 @@ function targetOf(context: AnyPolicyContext) {
};
}
if ('credential' in context && 'targetProjectId' in context) {
return {
credentialId: context.credential.id,
credentialType: context.credential.type,
projectId: context.targetProjectId,
};
}
// contentImport: the one point whose context can be either shape.
if ('transport' in context) {
if ('credential' in context) {

View File

@ -32,6 +32,7 @@ const DEADLINES_MS = {
workflowStart: 250,
workflowTransfer: 1_000,
credentialSave: 1_000,
credentialTransfer: 1_000,
credentialDecrypt: 250,
contentImport: 1_000,
} as const satisfies Record<EnforcementPoint, number>;

View File

@ -0,0 +1,111 @@
import type { Logger } from '@n8n/backend-common';
import type { AuthenticatedRequest } from '@n8n/db';
import type { Mocked } from 'vitest';
import type { NextFunction, Response } from 'express';
import { mock } from 'vitest-mock-extended';
import { ScimAuthMiddleware } from '../scim-auth.middleware';
import type { ScimSettingsService } from '../scim-settings.service';
import type { ScimTokenService } from '../scim-token.service';
describe('ScimAuthMiddleware', () => {
let scimTokenService: Mocked<ScimTokenService>;
let scimSettingsService: Mocked<ScimSettingsService>;
let middleware: ScimAuthMiddleware;
let res: Mocked<Response>;
let next: NextFunction;
const requestWithAuth = (authorization?: string) =>
mock<AuthenticatedRequest>({ headers: { authorization } });
beforeEach(() => {
scimTokenService = mock<ScimTokenService>();
scimSettingsService = mock<ScimSettingsService>();
scimSettingsService.isEnabled.mockResolvedValue(true);
middleware = new ScimAuthMiddleware(mock<Logger>(), scimTokenService, scimSettingsService);
res = mock<Response>();
res.status.mockReturnValue(res);
res.header.mockReturnValue(res);
next = vi.fn();
});
it('should reject all requests when provisioning is disabled', async () => {
scimSettingsService.isEnabled.mockResolvedValue(false);
scimTokenService.verifyApiKey.mockResolvedValue(true);
await middleware.authenticate(requestWithAuth('Bearer valid-token'), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(scimTokenService.verifyApiKey).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
it('should reject requests without an Authorization header', async () => {
await middleware.authenticate(requestWithAuth(undefined), res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n SCIM"');
expect(next).not.toHaveBeenCalled();
});
it('should reject non-bearer Authorization headers', async () => {
await middleware.authenticate(requestWithAuth('Basic dXNlcjpwYXNz'), res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('should reject invalid tokens', async () => {
scimTokenService.verifyApiKey.mockResolvedValue(false);
await middleware.authenticate(requestWithAuth('Bearer invalid-token'), res, next);
expect(scimTokenService.verifyApiKey).toHaveBeenCalledWith('invalid-token');
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('should call next() for valid tokens', async () => {
scimTokenService.verifyApiKey.mockResolvedValue(true);
await middleware.authenticate(requestWithAuth('Bearer valid-token'), res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should respond with 500 when verification throws', async () => {
scimTokenService.verifyApiKey.mockRejectedValue(new Error('db down'));
await middleware.authenticate(requestWithAuth('Bearer token'), res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(next).not.toHaveBeenCalled();
});
// RFC 7235 section 2.1: the auth scheme is case-insensitive, and providers
// do not all send the canonical casing.
it.each(['Bearer', 'bearer', 'BEARER', 'BeArEr'])('accepts the %s scheme', async (scheme) => {
scimTokenService.verifyApiKey.mockResolvedValue(true);
await middleware.authenticate(requestWithAuth(`${scheme} a-token`), res, next);
expect(scimTokenService.verifyApiKey).toHaveBeenCalledWith('a-token');
expect(next).toHaveBeenCalled();
});
it('tolerates extra whitespace around the token', async () => {
scimTokenService.verifyApiKey.mockResolvedValue(true);
await middleware.authenticate(requestWithAuth('Bearer a-token '), res, next);
expect(scimTokenService.verifyApiKey).toHaveBeenCalledWith('a-token');
});
it('rejects a scheme that merely starts with the word', async () => {
await middleware.authenticate(requestWithAuth('Bearerish a-token'), res, next);
expect(scimTokenService.verifyApiKey).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
});
});

View File

@ -0,0 +1,63 @@
import type { AuthenticatedRequest } from '@n8n/db';
import type { Response } from 'express';
import { mock } from 'vitest-mock-extended';
import { ScimDiscoveryController } from '../scim-discovery.controller';
// `scim-route` resolves its middleware from the container at module scope, so
// importing the controller would otherwise need a fully wired container. The
// route options do not affect the response bodies under test. `vi.mock` is
// hoisted above the import above.
vi.mock('../scim-route', () => ({ scimRoute: {} }));
/**
* An identity provider reads these to decide what to push. Anything advertised
* here must already exist, or the provider sends requests that 404.
*/
describe('ScimDiscoveryController', () => {
const controller = new ScimDiscoveryController();
const req = mock<AuthenticatedRequest>();
const call = (handler: 'getServiceProviderConfig' | 'getResourceTypes' | 'getSchemas') => {
const res = { header: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() };
controller[handler](req, res as unknown as Response);
return { header: res.header, body: res.json.mock.calls[0][0] };
};
it.each(['getServiceProviderConfig', 'getResourceTypes', 'getSchemas'] as const)(
'%s responds as application/scim+json',
(handler) => {
expect(call(handler).header).toHaveBeenCalledWith('Content-Type', 'application/scim+json');
},
);
it('advertises no operations it cannot serve', () => {
const { body } = call('getServiceProviderConfig');
expect(body.patch.supported).toBe(false);
expect(body.filter.supported).toBe(false);
expect(body.bulk.supported).toBe(false);
expect(body.changePassword.supported).toBe(false);
expect(body.sort.supported).toBe(false);
expect(body.etag.supported).toBe(false);
});
it('tells providers it authenticates with a bearer token', () => {
const { body } = call('getServiceProviderConfig');
expect(body.authenticationSchemes).toEqual([
expect.objectContaining({ type: 'oauthbearertoken', primary: true }),
]);
});
// These flip on in the PRs that add /scim/v2/Users and /scim/v2/Groups.
it.each(['getResourceTypes', 'getSchemas'] as const)(
'%s advertises nothing while no resource endpoint exists',
(handler) => {
const { body } = call(handler);
expect(body.totalResults).toBe(0);
expect(body.Resources).toEqual([]);
},
);
});

View File

@ -0,0 +1,85 @@
import type { SettingsRepository } from '@n8n/db';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { CacheService } from '@n8n/backend-services';
import { ScimSettingsService } from '../scim-settings.service';
describe('ScimSettingsService', () => {
let settingsRepository: Mocked<SettingsRepository>;
let cacheService: Mocked<CacheService>;
let service: ScimSettingsService;
beforeEach(() => {
settingsRepository = mock<SettingsRepository>();
cacheService = mock<CacheService>();
service = new ScimSettingsService(settingsRepository, cacheService);
});
describe('isEnabled', () => {
it('should default to disabled when no setting is stored', async () => {
cacheService.get.mockResolvedValue(undefined);
settingsRepository.findByKey.mockResolvedValue(null);
await expect(service.isEnabled()).resolves.toBe(false);
expect(cacheService.set).toHaveBeenCalledWith('scim.provisioning.enabled', 'false');
});
it('should read from the cache when available', async () => {
cacheService.get.mockResolvedValue('true');
await expect(service.isEnabled()).resolves.toBe(true);
expect(settingsRepository.findByKey).not.toHaveBeenCalled();
});
it('should read the stored setting on cache miss', async () => {
cacheService.get.mockResolvedValue(undefined);
settingsRepository.findByKey.mockResolvedValue(
mock({ key: 'scim.provisioning.enabled', value: 'true' }),
);
await expect(service.isEnabled()).resolves.toBe(true);
});
// Staying disabled is the security-relevant outcome: while it is false
// the middleware rejects every /scim/v2 request.
it('stays disabled when the cache says so, without reading the database', async () => {
cacheService.get.mockResolvedValue('false');
await expect(service.isEnabled()).resolves.toBe(false);
expect(settingsRepository.findByKey).not.toHaveBeenCalled();
});
it('stays disabled when the stored value is false', async () => {
cacheService.get.mockResolvedValue(undefined);
settingsRepository.findByKey.mockResolvedValue(
mock({ key: 'scim.provisioning.enabled', value: 'false' }),
);
await expect(service.isEnabled()).resolves.toBe(false);
});
// Anything that is not exactly 'true' must not enable provisioning.
it.each(['TRUE', '1', 'yes', ''])('treats %p as disabled', async (stored) => {
cacheService.get.mockResolvedValue(undefined);
settingsRepository.findByKey.mockResolvedValue(
mock({ key: 'scim.provisioning.enabled', value: stored }),
);
await expect(service.isEnabled()).resolves.toBe(false);
});
});
describe('setEnabled', () => {
it.each([true, false])('persists and caches %p', async (enabled) => {
await service.setEnabled(enabled);
expect(settingsRepository.upsert).toHaveBeenCalledWith(
{ key: 'scim.provisioning.enabled', value: String(enabled), loadOnStartup: true },
['key'],
);
expect(cacheService.set).toHaveBeenCalledWith('scim.provisioning.enabled', String(enabled));
});
});
});

View File

@ -0,0 +1,137 @@
import type { Logger, ModuleRegistry } from '@n8n/backend-common';
import type { AuthenticatedRequest, User } from '@n8n/db';
import type { ApiKey } from '@n8n/db';
import { ControllerRegistryMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
import type { Response } from 'express';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { ScimSettingsService } from '../scim-settings.service';
import { ScimTokenController } from '../scim-token.controller';
import type { ScimTokenService } from '../scim-token.service';
describe('ScimTokenController', () => {
let scimTokenService: Mocked<ScimTokenService>;
let scimSettingsService: Mocked<ScimSettingsService>;
let moduleRegistry: Mocked<ModuleRegistry>;
let controller: ScimTokenController;
const req = mock<AuthenticatedRequest>({ user: mock<User>({ id: 'user-1' }) });
const makeRes = () => {
const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() };
return res as unknown as Response & { status: ReturnType<typeof vi.fn>; json: typeof res.json };
};
beforeEach(() => {
vi.clearAllMocks();
scimTokenService = mock<ScimTokenService>();
scimSettingsService = mock<ScimSettingsService>();
moduleRegistry = mock<ModuleRegistry>();
scimTokenService.getScimBaseUrl.mockReturnValue('https://n8n.example.com/scim/v2');
controller = new ScimTokenController(
mock<Logger>(),
scimTokenService,
scimSettingsService,
moduleRegistry,
);
});
// Every one of these routes administers the credential an IdP uses to
// provision users, so none may be reachable without `scim:manage`.
describe('route access scopes', () => {
const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata(
ScimTokenController as never,
);
const routeCases = Array.from(metadata.routes.entries()).map(([handlerName, route]) => ({
handlerName,
route,
}));
it.each(routeCases)('$handlerName is gated by scim:manage', ({ route }) => {
expect(route.accessScope).toBeDefined();
expect(route.accessScope?.globalOnly).toBe(true);
expect(route.accessScope?.scope).toBe('scim:manage');
});
it('covers every route on the controller', () => {
expect(routeCases.map(({ handlerName }) => handlerName).sort()).toEqual([
'deleteToken',
'generateToken',
'getConfig',
'updateConfig',
]);
});
});
describe('GET /config', () => {
it('reports state without ever exposing the token', async () => {
scimSettingsService.isEnabled.mockResolvedValue(true);
scimTokenService.getTokenInfoForUser.mockResolvedValue({ hasToken: true, lastFour: 'ab12' });
const res = makeRes();
await controller.getConfig(req, res);
expect(res.status).toHaveBeenCalledWith(200);
const { data } = res.json.mock.calls[0][0];
expect(data).toEqual({
enabled: true,
hasToken: true,
tokenHint: 'ab12',
baseUrl: 'https://n8n.example.com/scim/v2',
});
expect(data).not.toHaveProperty('token');
});
it('returns 500 rather than leaking the error to the client', async () => {
scimSettingsService.isEnabled.mockRejectedValue(new Error('db down'));
const res = makeRes();
await controller.getConfig(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ message: 'Internal server error' });
});
});
describe('PATCH /config', () => {
it.each([true, false])('persists enabled=%s and echoes it back', async (enabled) => {
const res = makeRes();
await controller.updateConfig(req, res, { enabled });
expect(scimSettingsService.setEnabled).toHaveBeenCalledWith(enabled);
// Without this the settings snapshot keeps serving the previous value.
expect(moduleRegistry.refreshModuleSettings).toHaveBeenCalledWith('scim');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ data: { enabled } });
});
});
describe('POST /token', () => {
it('returns 201 with the token and base URL', async () => {
scimTokenService.rotateScimApiKey.mockResolvedValue(mock<ApiKey>({ apiKey: 'the-token' }));
const res = makeRes();
await controller.generateToken(req, res);
expect(scimTokenService.rotateScimApiKey).toHaveBeenCalledWith(req.user);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({
data: { token: 'the-token', baseUrl: 'https://n8n.example.com/scim/v2' },
});
});
});
describe('DELETE /token', () => {
it('deletes only the calling user keys', async () => {
const res = makeRes();
await controller.deleteToken(req, res);
expect(scimTokenService.deleteAllScimApiKeysForUser).toHaveBeenCalledWith(req.user);
expect(res.status).toHaveBeenCalledWith(200);
});
});
});

View File

@ -0,0 +1,90 @@
import type { ApiKey, User } from '@n8n/db';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { JwtService } from '@/services/jwt.service';
import type { UrlService } from '@n8n/backend-services';
import type { ScimApiKeyRepository } from '../database/scim-api-key.repository';
import { ScimTokenService } from '../scim-token.service';
describe('ScimTokenService', () => {
let apiKeyRepository: Mocked<ScimApiKeyRepository>;
let jwtService: Mocked<JwtService>;
let urlService: Mocked<UrlService>;
let service: ScimTokenService;
const createApiKey = (overrides: Partial<ApiKey> = {}): ApiKey =>
({
id: 'key-1',
userId: 'user-1',
apiKey: 'a-very-long-scim-token-value',
audience: 'scim-api',
user: { id: 'user-1', disabled: false } as User,
...overrides,
}) as ApiKey;
beforeEach(() => {
apiKeyRepository = mock<ScimApiKeyRepository>();
jwtService = mock<JwtService>();
urlService = mock<UrlService>();
service = new ScimTokenService(apiKeyRepository, jwtService, urlService);
});
describe('verifyApiKey', () => {
it('should accept a valid token belonging to an active user', async () => {
apiKeyRepository.findByKey.mockResolvedValue(createApiKey());
await expect(service.verifyApiKey('token')).resolves.toBe(true);
expect(jwtService.verify).toHaveBeenCalledWith('scimApiKey', 'token', { issuer: 'n8n' });
expect(apiKeyRepository.findByKey).toHaveBeenCalledWith('token', {});
});
it('should reject a token that fails JWT verification', async () => {
jwtService.verify.mockImplementation(() => {
throw new Error('jwt expired');
});
await expect(service.verifyApiKey('token')).resolves.toBe(false);
expect(apiKeyRepository.findByKey).not.toHaveBeenCalled();
});
it('should reject a token that is not stored in the database', async () => {
apiKeyRepository.findByKey.mockResolvedValue(null);
await expect(service.verifyApiKey('token')).resolves.toBe(false);
});
it('propagates a repository failure instead of reporting an invalid token', async () => {
apiKeyRepository.findByKey.mockRejectedValue(new Error('db down'));
await expect(service.verifyApiKey('token')).rejects.toThrow('db down');
});
it('should reject a token whose owner is disabled', async () => {
apiKeyRepository.findByKey.mockResolvedValue(
createApiKey({ user: { id: 'user-1', disabled: true } as User }),
);
await expect(service.verifyApiKey('token')).resolves.toBe(false);
});
});
describe('findScimApiKeyForUser', () => {
it('should return the existing key redacted', async () => {
apiKeyRepository.findByUserId.mockResolvedValue(createApiKey());
const result = await service.findScimApiKeyForUser(mock<User>({ id: 'user-1' }));
expect(result?.apiKey).not.toBe('a-very-long-scim-token-value');
expect(result?.apiKey).toMatch(/^\*+.{4}$/);
});
it('should return null when the user has no SCIM token', async () => {
apiKeyRepository.findByUserId.mockResolvedValue(null);
expect(await service.findScimApiKeyForUser(mock<User>({ id: 'user-1' }))).toBeNull();
});
});
});

View File

@ -0,0 +1,84 @@
import { ApiKey, type TransactionRunner } from '@n8n/db';
import type { DataSource, EntityManager } from '@n8n/typeorm';
import { mock } from 'vitest-mock-extended';
import { ScimApiKeyRepository } from '../scim-api-key.repository';
/**
* The audience is what scopes this repository to SCIM. These assert that every
* query carries it, so no caller can reach or destroy another kind of API key
* through a repository named for SCIM.
*/
describe('ScimApiKeyRepository', () => {
const SCIM = 'scim-api';
const ctx = {};
let manager: ReturnType<typeof mock<EntityManager>>;
let transactionRunner: ReturnType<typeof mock<TransactionRunner>>;
let repository: ScimApiKeyRepository;
beforeEach(() => {
manager = mock<EntityManager>();
transactionRunner = mock<TransactionRunner>();
// No ambient transaction, so the runner hands back the same context.
transactionRunner.run.mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any
(async (c: any, fn: any) => await fn(c)) as any,
);
repository = new ScimApiKeyRepository(mock<DataSource>({ manager }), transactionRunner);
});
it('scopes a lookup by user to the SCIM audience', async () => {
await repository.findByUserId('user-1', ctx);
expect(manager.findOne).toHaveBeenCalledWith(ApiKey, {
where: { userId: 'user-1', audience: SCIM },
});
});
it('scopes a lookup by key to the SCIM audience', async () => {
await repository.findByKey('a-token', ctx);
expect(manager.findOne).toHaveBeenCalledWith(ApiKey, {
where: { apiKey: 'a-token', audience: SCIM },
relations: ['user'],
});
});
it('only ever deletes the user SCIM key, never their other API keys', async () => {
await repository.deleteAllForUser('user-1', ctx);
expect(manager.delete).toHaveBeenCalledWith(ApiKey, { userId: 'user-1', audience: SCIM });
});
describe('replaceForUser', () => {
beforeEach(() => {
manager.findOneByOrFail.mockResolvedValue(mock<ApiKey>());
vi.spyOn(repository, 'create').mockImplementation((data) => data as ApiKey);
});
it('replaces within the SCIM audience only, in one transaction', async () => {
await repository.replaceForUser('user-1', 'new-token', ctx);
expect(transactionRunner.run).toHaveBeenCalled();
expect(manager.delete).toHaveBeenCalledWith(ApiKey, { userId: 'user-1', audience: SCIM });
expect(manager.insert).toHaveBeenCalledWith(
ApiKey,
expect.objectContaining({ userId: 'user-1', apiKey: 'new-token', audience: SCIM }),
);
});
// `api_key` is unique on (userId, label), so the constant label is what
// stops a racing rotation inserting a second live token. Give each key
// its own label and that protection is gone.
it('labels every SCIM key identically, which is what keeps it to one per user', async () => {
await repository.replaceForUser('user-1', 'token-a', ctx);
await repository.replaceForUser('user-1', 'token-b', ctx);
const labels = manager.insert.mock.calls.map(
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any
([, row]: any) => row.label as string,
);
expect(labels).toEqual(['SCIM Provisioning API Key', 'SCIM Provisioning API Key']);
});
});
});

View File

@ -0,0 +1,72 @@
import { ApiKey, BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db';
import { Service } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { TOKEN_PURPOSES } from '@/services/token-purposes';
/**
* SCIM provisioning keys are ordinary API keys reserved by their audience.
* The queries live here so the service stays free of TypeORM, and so
* replacing a key stays one unit of work.
*
* Every query is pinned to the SCIM audience. The audience is not a parameter
* on purpose: it is what scopes this repository to SCIM, so a caller must not
* be able to reach another kind of key through it.
*/
@Service()
export class ScimApiKeyRepository extends BaseRepository<ApiKey> {
private readonly audience = TOKEN_PURPOSES.scimApiKey;
/**
* Names the row for anyone reading the table. Never shown in the UI.
*
* Keep this constant. `api_key` is unique on (userId, label), so a fixed
* label is what stops a user ending up with two live SCIM keys: a second
* rotation racing the first is rejected by that index instead of quietly
* inserting a second valid token. Per-key labels would remove the only
* thing enforcing one key per user.
*/
private readonly label = 'SCIM Provisioning API Key';
constructor(dataSource: DataSource, transactionRunner: TransactionRunner) {
super(ApiKey, dataSource.manager, transactionRunner);
}
async findByUserId(userId: string, ctx: OperationContext) {
return await this.managerFor(ctx).findOne(ApiKey, {
where: { userId, audience: this.audience },
});
}
async findByKey(apiKey: string, ctx: OperationContext) {
return await this.managerFor(ctx).findOne(ApiKey, {
where: { apiKey, audience: this.audience },
relations: ['user'],
});
}
/**
* Replace the user's SCIM key.
*
* A concurrent rotation is rejected by the (userId, label) index rather
* than being allowed through, so the user never holds two valid tokens.
* The caller sees a failure and retries. Making that a clean 409, and
* handling the case where the user already has a public API key of this
* name, both need a unique index on (userId) for this audience, so they
* wait for the PR that carries a migration.
*/
async replaceForUser(userId: string, apiKey: string, ctx: OperationContext) {
const { audience, label } = this;
return await this.runInTransaction(ctx, async (tx) => {
await tx.delete(ApiKey, { userId, audience });
await tx.insert(ApiKey, this.create({ userId, apiKey, audience, scopes: [], label }));
return await tx.findOneByOrFail(ApiKey, { apiKey });
});
}
async deleteAllForUser(userId: string, ctx: OperationContext) {
await this.managerFor(ctx).delete(ApiKey, { userId, audience: this.audience });
}
}

View File

@ -0,0 +1,83 @@
import { Logger } from '@n8n/backend-common';
import type { AuthenticatedRequest } from '@n8n/db';
import { Service } from '@n8n/di';
import type { NextFunction, RequestHandler, Response } from 'express';
import { ScimSettingsService } from './scim-settings.service';
import { ScimTokenService } from './scim-token.service';
/**
* Middleware to authenticate SCIM requests using Bearer tokens
*/
@Service()
export class ScimAuthMiddleware {
constructor(
private readonly logger: Logger,
private readonly scimTokenService: ScimTokenService,
private readonly scimSettingsService: ScimSettingsService,
) {}
getAuthMiddleware(): RequestHandler {
return this.authenticate.bind(this) as RequestHandler;
}
/**
* Express middleware function to validate SCIM Bearer tokens
*/
async authenticate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
if (!(await this.scimSettingsService.isEnabled())) {
return res
.status(403)
.header('Content-Type', 'application/scim+json')
.json({
schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
status: '403',
detail: 'SCIM provisioning is disabled',
});
}
// RFC 7235 section 2.1 makes the auth scheme case-insensitive, and
// some providers send `bearer`.
const bearer = /^Bearer[ \t]+(.+)$/i.exec(req.headers.authorization ?? '');
if (!bearer) {
this.logger.warn('SCIM: Missing or invalid Authorization header');
return this.unauthorized(res, 'Authorization header with Bearer token required');
}
const token = bearer[1].trim();
const isValid = await this.scimTokenService.verifyApiKey(token);
if (!isValid) {
this.logger.warn('SCIM: Invalid API key provided');
return this.unauthorized(res, 'Invalid SCIM API key');
}
return next();
} catch (error) {
this.logger.error('SCIM: Error during authentication', { error });
return res
.status(500)
.header('Content-Type', 'application/scim+json')
.json({
schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
status: '500',
detail: 'Internal server error',
});
}
}
private unauthorized(res: Response, detail: string) {
return res
.status(401)
.header('WWW-Authenticate', 'Bearer realm="n8n SCIM"')
.header('Content-Type', 'application/scim+json')
.json({
schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
status: '401',
detail,
});
}
}

View File

@ -0,0 +1,88 @@
import type { AuthenticatedRequest } from '@n8n/db';
import { Get, RootLevelController } from '@n8n/decorators';
import type { Response } from 'express';
import { scimRoute } from './scim-route';
/**
* SCIM 2.0 discovery endpoints (RFC 7644 section 4).
*
* An identity provider reads these before it pushes anything, to learn which
* resources and operations this server supports.
*
* No resource endpoint exists yet, so this advertises nothing: no resource
* types, no schemas, and no PATCH or filter support. A provider that reads a
* capability here will use it, and would then get a 404. Each entry is added
* by the PR that implements the endpoint behind it.
*/
@RootLevelController('/scim/v2')
export class ScimDiscoveryController {
/**
* GET /scim/v2/ServiceProviderConfig
* Return service provider configuration
*/
@Get('/ServiceProviderConfig', scimRoute)
getServiceProviderConfig(_req: AuthenticatedRequest, res: Response) {
return res.header('Content-Type', 'application/scim+json').json({
schemas: ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'],
documentationUri: 'https://docs.n8n.io/user-management/scim/',
patch: {
supported: false,
},
bulk: {
supported: false,
maxOperations: 0,
maxPayloadSize: 0,
},
filter: {
supported: false,
maxResults: 0,
},
changePassword: {
supported: false,
},
sort: {
supported: false,
},
etag: {
supported: false,
},
authenticationSchemes: [
{
type: 'oauthbearertoken',
name: 'OAuth Bearer Token',
description: 'Authentication scheme using the OAuth Bearer Token Standard',
specUri: 'https://tools.ietf.org/html/rfc6750',
documentationUri: 'https://docs.n8n.io/user-management/scim/',
primary: true,
},
],
});
}
/**
* GET /scim/v2/ResourceTypes
* Return supported resource types
*/
@Get('/ResourceTypes', scimRoute)
getResourceTypes(_req: AuthenticatedRequest, res: Response) {
return res.header('Content-Type', 'application/scim+json').json({
schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
totalResults: 0,
Resources: [],
});
}
/**
* GET /scim/v2/Schemas
* Return supported schemas
*/
@Get('/Schemas', scimRoute)
getSchemas(_req: AuthenticatedRequest, res: Response) {
return res.header('Content-Type', 'application/scim+json').json({
schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
totalResults: 0,
Resources: [],
});
}
}

View File

@ -0,0 +1,31 @@
import { createIpRateLimit } from '@n8n/decorators';
import { Container } from '@n8n/di';
import type { RequestHandler } from 'express';
import { ScimAuthMiddleware } from './scim-auth.middleware';
import { ScimConfig } from './scim.config';
const scimAuth = () => Container.get(ScimAuthMiddleware).getAuthMiddleware();
const scimConfig = Container.get(ScimConfig);
/**
* Options shared by all SCIM endpoints: the built-in cookie auth is skipped
* and replaced by bearer-token auth (`ScimAuthMiddleware`), since requests
* come from the identity provider, not a browser session.
*/
export const scimRoute: {
skipAuth: boolean;
usesTemplates: boolean;
allowBots: boolean;
middlewares: RequestHandler[];
ipRateLimit: ReturnType<typeof createIpRateLimit>;
} = {
skipAuth: true,
usesTemplates: true,
// Identity providers call these with their own User-Agent, which the global
// bot filter would otherwise drop before `ScimAuthMiddleware` runs.
allowBots: true,
middlewares: [scimAuth()],
ipRateLimit: createIpRateLimit(scimConfig.rateLimit),
};

View File

@ -0,0 +1,40 @@
import { SettingsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { CacheService } from '@n8n/backend-services';
const ENABLED_KEY = 'scim.provisioning.enabled';
/**
* Persists instance-wide SCIM settings. Provisioning is disabled by
* default; while disabled, all /scim/v2 endpoints reject requests but the
* stored token is kept.
*/
@Service()
export class ScimSettingsService {
constructor(
private readonly settingsRepository: SettingsRepository,
private readonly cacheService: CacheService,
) {}
async isEnabled(): Promise<boolean> {
const cached = await this.cacheService.get<string>(ENABLED_KEY);
if (cached !== undefined) return cached === 'true';
const row = await this.settingsRepository.findByKey(ENABLED_KEY);
const enabled = row?.value === 'true';
await this.cacheService.set(ENABLED_KEY, enabled.toString());
return enabled;
}
async setEnabled(enabled: boolean): Promise<void> {
await this.settingsRepository.upsert(
{ key: ENABLED_KEY, value: enabled.toString(), loadOnStartup: true },
['key'],
);
await this.cacheService.set(ENABLED_KEY, enabled.toString());
}
}

View File

@ -0,0 +1,110 @@
import { ScimConfigPatchDto } from '@n8n/api-types';
import { Logger, ModuleRegistry } from '@n8n/backend-common';
import type { AuthenticatedRequest } from '@n8n/db';
import { Body, Delete, Get, GlobalScope, Patch, Post, RestController } from '@n8n/decorators';
import type { Response } from 'express';
import { ScimSettingsService } from './scim-settings.service';
import { ScimTokenService } from './scim-token.service';
/**
* REST Controller for SCIM configuration and token management.
* These endpoints require authentication and the `scim:manage` scope.
*
* The token value is returned exactly once, by the POST endpoint that
* creates it. The config endpoint only ever exposes whether a token
* exists and its last four characters.
*/
@RestController('/scim')
export class ScimTokenController {
constructor(
private readonly logger: Logger,
private readonly scimTokenService: ScimTokenService,
private readonly scimSettingsService: ScimSettingsService,
private readonly moduleRegistry: ModuleRegistry,
) {}
/**
* GET /rest/scim/config
* Current provisioning state for the settings screen.
*/
@Get('/config')
@GlobalScope('scim:manage')
async getConfig(req: AuthenticatedRequest, res: Response) {
try {
const [enabled, tokenInfo] = await Promise.all([
this.scimSettingsService.isEnabled(),
this.scimTokenService.getTokenInfoForUser(req.user),
]);
return res.status(200).json({
data: {
enabled,
hasToken: tokenInfo.hasToken,
tokenHint: tokenInfo.lastFour,
baseUrl: this.scimTokenService.getScimBaseUrl(),
},
});
} catch (error) {
this.logger.error('Error getting SCIM config', { error });
return res.status(500).json({ message: 'Internal server error' });
}
}
/**
* PATCH /rest/scim/config
* Enable or disable SCIM provisioning. The stored token is kept when
* provisioning is turned off.
*/
@Patch('/config')
@GlobalScope('scim:manage')
async updateConfig(_req: AuthenticatedRequest, res: Response, @Body body: ScimConfigPatchDto) {
try {
await this.scimSettingsService.setEnabled(body.enabled);
// `/rest/module-settings` serves a cached snapshot, so refresh it or
// clients keep reading the previous value.
await this.moduleRegistry.refreshModuleSettings('scim');
return res.status(200).json({ data: { enabled: body.enabled } });
} catch (error) {
this.logger.error('Error updating SCIM config', { error });
return res.status(500).json({ message: 'Internal server error' });
}
}
/**
* POST /rest/scim/token
* Generate or rotate the SCIM token. This is the only place the full
* token value is ever returned.
*/
@Post('/token')
@GlobalScope('scim:manage')
async generateToken(req: AuthenticatedRequest, res: Response) {
try {
const apiKey = await this.scimTokenService.rotateScimApiKey(req.user);
const baseUrl = this.scimTokenService.getScimBaseUrl();
return res.status(201).json({ data: { token: apiKey.apiKey, baseUrl } });
} catch (error) {
this.logger.error('Error generating SCIM token', { error });
return res.status(500).json({ message: 'Internal server error' });
}
}
/**
* DELETE /rest/scim/token
* Delete SCIM token for the authenticated user
*/
@Delete('/token')
@GlobalScope('scim:manage')
async deleteToken(req: AuthenticatedRequest, res: Response) {
try {
await this.scimTokenService.deleteAllScimApiKeysForUser(req.user);
return res.status(200).json({ data: { success: true } });
} catch (error) {
this.logger.error('Error deleting SCIM token', { error });
return res.status(500).json({ message: 'Internal server error' });
}
}
}

View File

@ -0,0 +1,120 @@
import { type OperationContext, type User } from '@n8n/db';
import { Service } from '@n8n/di';
import { randomUUID } from 'crypto';
import { JwtService } from '@/services/jwt.service';
import { UrlService } from '@n8n/backend-services';
import { ScimApiKeyRepository } from './database/scim-api-key.repository';
const API_KEY_PURPOSE = 'scimApiKey' as const;
const API_KEY_ISSUER = 'n8n';
const REDACT_API_KEY_REVEAL_COUNT = 4;
const REDACT_API_KEY_MAX_LENGTH = 10;
const REDACT_API_KEY_MIN_HIDDEN_CHARS = 6;
/**
* Service for managing SCIM API keys, including creation, retrieval, deletion, and authentication.
*/
@Service()
export class ScimTokenService {
constructor(
private readonly apiKeyRepository: ScimApiKeyRepository,
private readonly jwtService: JwtService,
private readonly urlService: UrlService,
) {}
private signApiKey(user: User) {
return this.jwtService.sign(API_KEY_PURPOSE, {
sub: user.id,
iss: API_KEY_ISSUER,
jti: randomUUID(),
});
}
/**
* Whether the user has a SCIM token and, if so, its last four
* characters for display. The full value is never exposed here.
*/
async getTokenInfoForUser(
user: User,
ctx: OperationContext = {},
): Promise<{ hasToken: boolean; lastFour: string | null }> {
const apiKey = await this.apiKeyRepository.findByUserId(user.id, ctx);
return {
hasToken: !!apiKey,
lastFour: apiKey ? apiKey.apiKey.slice(-REDACT_API_KEY_REVEAL_COUNT) : null,
};
}
/**
* Find SCIM API key for a user
*/
async findScimApiKeyForUser(user: User, { redact = true } = {}, ctx: OperationContext = {}) {
const apiKey = await this.apiKeyRepository.findByUserId(user.id, ctx);
if (apiKey && redact) {
apiKey.apiKey = this.redactApiKey(apiKey.apiKey);
}
return apiKey;
}
/**
* Verify a SCIM API key. The key must be a valid JWT, exist in the
* database, and belong to a user that still exists and is not disabled.
*/
async verifyApiKey(apiKey: string, ctx: OperationContext = {}): Promise<boolean> {
try {
this.jwtService.verify(API_KEY_PURPOSE, apiKey, { issuer: API_KEY_ISSUER });
} catch {
// A malformed, expired or wrongly-audienced token is simply not valid.
return false;
}
// Everything below can only fail for reasons that are not the caller's
// fault, so let those propagate. Reporting a database outage as an
// invalid token would tell the IdP to treat its credential as revoked.
const key = await this.apiKeyRepository.findByKey(apiKey, ctx);
return !!key && !!key.user && !key.user.disabled;
}
/**
* Delete all SCIM API keys for a user
*/
async deleteAllScimApiKeysForUser(user: User, ctx: OperationContext = {}) {
await this.apiKeyRepository.deleteAllForUser(user.id, ctx);
}
/**
* Rotate SCIM API key for a user
*/
async rotateScimApiKey(user: User, ctx: OperationContext = {}) {
return await this.apiKeyRepository.replaceForUser(user.id, this.signApiKey(user), ctx);
}
/**
* Get SCIM base URL
*/
getScimBaseUrl(): string {
return `${this.urlService.getInstanceBaseUrl()}/scim/v2`;
}
/**
* Redact API key for display purposes
*/
private redactApiKey(apiKey: string) {
if (REDACT_API_KEY_REVEAL_COUNT >= apiKey.length - REDACT_API_KEY_MIN_HIDDEN_CHARS) {
return '*'.repeat(apiKey.length);
}
const visiblePart = apiKey.slice(-REDACT_API_KEY_REVEAL_COUNT);
const redactedPart = '*'.repeat(
Math.max(0, REDACT_API_KEY_MAX_LENGTH - REDACT_API_KEY_REVEAL_COUNT),
);
return redactedPart + visiblePart;
}
}

View File

@ -0,0 +1,14 @@
import { Config, Env } from '@n8n/config';
import { z } from 'zod';
/** Configuration for the SCIM provisioning module. */
@Config
export class ScimConfig {
/**
* Maximum number of requests to the SCIM endpoints (`/scim/v2/*`) per IP
* per 5 minutes. The default is sized to accommodate an identity
* provider's initial full sync. Set to `0` to disable IP rate limiting.
*/
@Env('N8N_SCIM_RATE_LIMIT', z.number({ coerce: true }).int().nonnegative())
rateLimit: number = 600;
}

View File

@ -0,0 +1,30 @@
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule, OnShutdown } from '@n8n/decorators';
import { Container } from '@n8n/di';
/**
* SCIM 2.0 module for user provisioning.
* Implements System for Cross-domain Identity Management (RFC 7644) for
* automated user lifecycle management.
*/
@BackendModule({ name: 'scim', licenseFlag: 'feat:scim', instanceTypes: ['main'] })
export class ScimModule implements ModuleInterface {
async init() {
// Import controllers to register routes
await import('./scim-discovery.controller.js');
await import('./scim-token.controller.js');
}
/**
* Settings exposed to the frontend under `/rest/module-settings`.
* The response shape will be `{ scim: { scimEnabled: boolean } }`.
*/
async settings() {
const { ScimSettingsService } = await import('./scim-settings.service.js');
return { scimEnabled: await Container.get(ScimSettingsService).isEnabled() };
}
@OnShutdown()
async shutdown() {}
}

View File

@ -101,13 +101,14 @@ A rule of this kind is written on `/credential-type-policies` and
`/projects/:projectId/credential-type-policies`, behind `credentialTypePolicy:manage` — its
own permission, because blocking `oAuth2Api` is a wider lever than blocking one node.
It implements all seven points:
It implements all eight points:
| Point | What it reads |
| ------------------- | -------------------------------------------------- |
| the five workflow points | the keys of every node's `credentials` map |
| `credentialSave` | the type of the credential being written |
| `credentialDecrypt` | `credentialType` — the credential's own type |
| Point | What it reads |
| ------------------------ | ----------------------------------------------------- |
| the five workflow points | the keys of every node's `credentials` map |
| `credentialSave` | the type of the credential being written |
| `credentialTransfer` | the credential's type, against the **target** project |
| `credentialDecrypt` | `credentialType` — the credential's own type |
Two differences from the node check are the point of the whole thing:
@ -284,7 +285,7 @@ through a sealed repository method, and the lint rule that guards that has no al
| File | Role |
| ------------------------------------------------- | ------------------------------------------------------------------------------- |
| `node-type-policy.check.ts` | Node types: the six points, the save diff, the violations |
| `credential-type-policy.check.ts` | Credential types: all seven points, including `credentialSave` |
| `credential-type-policy.check.ts` | Credential types: all eight points, including `credentialSave` |
| `policy-evaluator.ts` | Pure evaluation: first match per scope, then the instance ∩ project composition |
| `policy-shadow-lint.ts` | Warns at write time about rules an earlier rule already covers |
| `package-resolver.ts` | Resolves a type's package per `kind`, for the `package` selector |

View File

@ -504,6 +504,31 @@ describe('CredentialTypePolicyCheck', () => {
});
});
describe('onCredentialTransfer', () => {
it('judges the credential type against the target project', async () => {
const result = await check.onCredentialTransfer({
credential: { id: 'cred-1', type: SLACK_API },
targetProjectId: 'target-project',
});
expect(result.violations.map((violation) => violation.subject)).toEqual([SLACK_API]);
expect(service.evaluateComposedTypesFor).toHaveBeenCalledWith(
'credential-types',
'target-project',
[SLACK_API],
);
});
it('allows an available type', async () => {
const result = await check.onCredentialTransfer({
credential: { id: 'cred-1', type: HTTP_BASIC },
targetProjectId: 'target-project',
});
expect(result.violations).toEqual([]);
});
});
describe('onCredentialDecrypt', () => {
it('vetoes the decryption when the credential type is denied', async () => {
const result = await check.onCredentialDecrypt(decryptOf(SLACK_API));

View File

@ -4,6 +4,7 @@ import type {
ContentImportContext,
CredentialDecryptContext,
CredentialSaveContext,
CredentialTransferContext,
PolicedWorkflow,
PolicyCheckResult,
PolicyViolation,
@ -138,6 +139,13 @@ export class CredentialTypePolicyCheck implements RegisteredPolicyCheck {
return await this.checkTypes([credential.type], projectId);
}
async onCredentialTransfer({
credential,
targetProjectId,
}: CredentialTransferContext): Promise<PolicyCheckResult> {
return await this.checkTypes([credential.type], targetProjectId);
}
/**
* Locks on the credential's own type, never on the node asking, which is what closes the
* gap a node policy alone leaves: a blocked `slackApi` is refused to the Slack node and to

View File

@ -54,6 +54,14 @@ const enforceCalls = (service: PolicyEnforcementService) => ({
},
{ kind: 'system', reason: 'execution' },
),
credentialTransfer: async () =>
await service.enforceCredentialTransfer(
{
credential: { id: 'cred-1', type: 'slackApi' },
targetProjectId: 'proj-2',
},
{ kind: 'system', reason: 'execution' },
),
credentialDecrypt: async () =>
await service.enforceCredentialDecrypt(
{
@ -97,6 +105,11 @@ const evaluateCalls = (service: PolicyEnforcementService) => ({
storedCredential: null,
projectId: 'proj-1',
}),
credentialTransfer: async () =>
await service.evaluateCredentialTransfer({
credential: { id: 'cred-1', type: 'slackApi' },
targetProjectId: 'proj-2',
}),
credentialDecrypt: async () =>
await service.evaluateCredentialDecrypt({
credentialType: 'slackApi',

View File

@ -2,6 +2,7 @@ import type {
ContentImportContext,
CredentialDecryptContext,
CredentialSaveContext,
CredentialTransferContext,
EnforcementPoint,
PolicyDecision,
WorkflowPublishContext,
@ -19,6 +20,7 @@ type PolicyContexts = {
workflowStart: WorkflowStartContext;
workflowTransfer: WorkflowTransferContext;
credentialSave: CredentialSaveContext;
credentialTransfer: CredentialTransferContext;
credentialDecrypt: CredentialDecryptContext;
contentImport: ContentImportContext;
};

View File

@ -2,6 +2,7 @@ import type {
ContentImportContext,
CredentialDecryptContext,
CredentialSaveContext,
CredentialTransferContext,
EnforcementPoint,
PolicyDecision,
WorkflowPublishContext,
@ -132,6 +133,22 @@ export class PolicyEnforcementService {
return await this.evaluate('credentialSave', context);
}
async enforceCredentialTransfer(
context: CredentialTransferContext,
actor: PolicyActor,
): Promise<PolicyCleared<'credentialTransfer'>> {
return await this.enforce(
'credentialTransfer',
context,
actor,
credentialSubject(context.credential),
);
}
async evaluateCredentialTransfer(context: CredentialTransferContext): Promise<PolicyDecision> {
return await this.evaluate('credentialTransfer', context);
}
async enforceCredentialDecrypt(
context: CredentialDecryptContext,
actor: PolicyActor,

View File

@ -19,16 +19,6 @@ export type PaginatedRequest = AuthenticatedRequest<
lastId?: string;
}
>;
export declare namespace WorkflowRequest {
type Activate = AuthenticatedRequest<
{ id: string },
{},
{ versionId?: string; name?: string; description?: string },
{}
>;
type GetVersion = AuthenticatedRequest<{ id: string; versionId: string }, {}, {}, {}>;
}
export declare namespace PackageRequest {
type Import = AuthenticatedRequest<
{},

View File

@ -15,7 +15,7 @@ import { DateTime } from 'luxon';
import { UserError } from 'n8n-workflow';
import { BadRequestError, ForbiddenError } from '@n8n/errors';
import { InsightsService } from '@/modules/insights/insights.service';
import { InsightsService } from '@n8n/backend-module-insights';
@PublicApiController('/insights')
export class InsightsPublicController {

View File

@ -3,6 +3,7 @@ import {
CreatedWorkflowPublicDto,
CreateWorkflowPublicDto,
DeletedWorkflowPublicDto,
DeprecatedWorkflowVersionPublicDto,
GetWorkflowQueryDto,
ListWorkflowHistoryQueryDto,
ListWorkflowsQueryDto,
@ -75,7 +76,8 @@ import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-hi
import { WorkflowService } from '@/workflows/workflow.service';
import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
const DEPRECATED_ALIAS_SINCE = new Date('2026-07-23T00:00:00Z');
const ACTIVATE_DEACTIVATE_DEPRECATED_SINCE = new Date('2026-07-23T00:00:00Z');
const OLD_VERSION_PATH_DEPRECATED_SINCE = new Date('2026-08-26T00:00:00Z');
const UPDATE_CONFLICT_DESCRIPTION =
'Conflict, e.g. re-publication blocked by an open workflow review (then `reason` and ' +
@ -650,7 +652,7 @@ export class WorkflowsPublicController {
}
@Post('/:workflowId/activate')
@Deprecated({ since: DEPRECATED_ALIAS_SINCE })
@Deprecated({ since: ACTIVATE_DEACTIVATE_DEPRECATED_SINCE })
@ApiKeyScope('workflow:activate')
@ProjectScope('workflow:publish')
@ApiSummary('Publish a workflow')
@ -675,7 +677,7 @@ export class WorkflowsPublicController {
}
@Post('/:workflowId/deactivate')
@Deprecated({ since: DEPRECATED_ALIAS_SINCE })
@Deprecated({ since: ACTIVATE_DEACTIVATE_DEPRECATED_SINCE })
@ApiKeyScope('workflow:deactivate')
@ProjectScope('workflow:unpublish')
@ApiSummary('Deactivate a workflow')
@ -831,4 +833,44 @@ export class WorkflowsPublicController {
return tags.map(toPublicTag);
}
/**
* Keep this route as the last registered route in the controller, so that it does not shadow other routes.
*/
@Get('/:workflowId/:workflowVersionId')
@Deprecated({ since: OLD_VERSION_PATH_DEPRECATED_SINCE })
@ApiKeyScope('workflow:read')
@ProjectScope('workflow:read')
@ApiSummary('Retrieves a specific version of a workflow')
@ApiDescription(
'Deprecated: use GET /workflows/{workflowId}/versions/{workflowVersionId} instead. ' +
'Retrieves a specific version of a workflow from workflow history.',
)
@ApiTags(['Workflow'])
@ApiResponse(200, DeprecatedWorkflowVersionPublicDto)
@ApiErrorResponse(404)
async getDeprecatedWorkflowVersion(
req: AuthenticatedRequest,
_res: Response,
@Param('workflowId', workflowIdParamSchema) workflowId: string,
@Param('workflowVersionId', workflowVersionIdParamSchema) workflowVersionId: string,
): Promise<DeprecatedWorkflowVersionPublicDto> {
try {
const version = await this.workflowHistoryService.getVersion(
req.user,
workflowId,
workflowVersionId,
{ includePublishHistory: false },
);
this.eventService.emit('user-retrieved-workflow-version', {
userId: req.user.id,
publicApi: true,
});
return toPublicWorkflowVersion(version);
} catch (error) {
throw new NotFoundError('Version not found');
}
}
}

View File

@ -0,0 +1,181 @@
operationId: getDeprecatedWorkflowVersion
tags:
- Workflow
summary: Retrieves a specific version of a workflow
description: 'Deprecated: use GET /workflows/{workflowId}/versions/{workflowVersionId} instead. Retrieves a specific version of a workflow from workflow history.'
x-required-scope: workflow:read
deprecated: true
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
required: true
description: The ID of the workflow.
name: workflowId
in: path
- schema:
type: string
required: true
description: The ID of the workflow version.
name: workflowVersionId
in: path
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
type: object
properties:
versionId:
type: string
workflowId:
type: string
nodes:
type: array
description: Nodes that make up the workflow
items:
type: object
properties:
id:
type: string
example: 0f5532f9-36ba-4bef-86c7-30d607400b15
name:
type: string
example: Jira
webhookId:
type: string
disabled:
type: boolean
notesInFlow:
type: boolean
notes:
type: string
type:
type: string
example: n8n-nodes-base.jira
typeVersion:
type: number
example: 1
executeOnce:
type: boolean
example: false
alwaysOutputData:
type: boolean
example: false
retryOnFail:
type: boolean
example: false
maxTries:
type: number
waitBetweenTries:
type: number
continueOnFail:
type: boolean
example: false
description: use onError instead
deprecated: true
onError:
type: string
example: stopWorkflow
position:
type: array
items:
type: number
example:
- -100
- 80
parameters:
type: object
additionalProperties: true
credentials:
type: object
example:
jiraSoftwareCloudApi:
name: jiraApi
customTelemetryTags:
type: object
properties:
tag:
type: array
items:
type: object
properties:
key:
type: string
value:
type: string
required:
- key
- value
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
connections:
type: object
description: Connections between nodes, keyed by source node name
example:
Jira:
main:
- - node: Jira
type: main
index: 0
nodeGroups:
type: array
description: Visual groupings of nodes shown as frames on the canvas
items:
type: object
properties:
id:
type: string
description: Unique identifier for the node group
example: 9b1c8e2a-4d3f-4a6b-8c7d-1e2f3a4b5c6d
name:
type: string
description: Display name of the node group
example: Data processing
description:
type: string
maxLength: 155
description: Optional plain-text description of the node group
example: Cleans and normalizes incoming records
nodeIds:
type: array
description: IDs of the nodes that belong to this group
items:
type: string
required:
- id
- name
- nodeIds
authors:
type: string
name:
type: string
nullable: true
description:
type: string
nullable: true
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
required:
- versionId
- workflowId
- nodes
- connections
- authors
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml

View File

@ -1,32 +0,0 @@
get:
x-eov-operation-id: getWorkflowVersion
x-required-scope: workflow:read
x-eov-operation-handler: v1/handlers/workflows/workflows.handler
tags:
- Workflow
summary: Retrieves a specific version of a workflow
description: >-
Deprecated: use GET /workflows/{workflowId}/versions/{workflowVersionId}
instead.
Retrieves a specific version of a workflow from workflow history.
deprecated: true
parameters:
- $ref: '../schemas/parameters/workflowId.yml'
- name: versionId
in: path
required: true
description: The version ID to retrieve
schema:
type: string
example: 'abc123-def456-ghi789'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/workflowVersion.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View File

@ -1,60 +0,0 @@
type: object
additionalProperties: false
required:
- versionId
- workflowId
- nodes
- connections
- authors
properties:
versionId:
type: string
readOnly: true
description: The version ID of this workflow snapshot
example: abc123-def456
workflowId:
type: string
readOnly: true
description: The workflow ID this version belongs to
example: 2tUt1wbLX592XDdX
nodes:
type: array
items:
$ref: './node.yml'
description: Nodes as they were in this version
readOnly: true
connections:
type: object
example: { Jira: { main: [[{ node: 'Jira', type: 'main', index: 0 }]] } }
description: Connections as they were in this version
readOnly: true
nodeGroups:
type: array
readOnly: true
description: Node groups as they were in this version
items:
$ref: './workflowNodeGroup.yml'
authors:
type: string
readOnly: true
description: Authors who created this version
example: John Doe
name:
type: string
nullable: true
description: Workflow name at this version
example: Workflow 1
description:
type: string
nullable: true
description: Workflow description at this version
createdAt:
type: string
format: date-time
readOnly: true
description: When this version was created
updatedAt:
type: string
format: date-time
readOnly: true
description: When this version was last updated

View File

@ -1,52 +0,0 @@
import { EventService } from '@n8n/backend-services';
import { Container } from '@n8n/di';
import { NotFoundError } from '@n8n/errors';
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import type { WorkflowRequest } from '../../../types';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import {
publicApiScope,
projectScope,
deprecated,
} from '../../shared/middlewares/global.middleware';
const VERSION_PATH_DEPRECATED_SINCE = new Date('2026-08-26T00:00:00Z');
type WorkflowHandlers = {
getWorkflowVersion: PublicAPIEndpoint<WorkflowRequest.GetVersion>;
};
const workflowHandlers: WorkflowHandlers = {
getWorkflowVersion: [
deprecated({ since: VERSION_PATH_DEPRECATED_SINCE }),
publicApiScope('workflow:read'),
projectScope('workflow:read', 'workflow'),
async (req, res) => {
const { id: workflowId, versionId } = req.params;
try {
const version = await Container.get(WorkflowHistoryService).getVersion(
req.user,
workflowId,
versionId,
{ includePublishHistory: false },
);
Container.get(EventService).emit('user-retrieved-workflow-version', {
userId: req.user.id,
publicApi: true,
});
const { autosaved, ...versionWithoutInternalFields } = version;
return res.json(versionWithoutInternalFields);
} catch {
throw new NotFoundError('Version not found');
}
},
],
};
export = workflowHandlers;

View File

@ -417,3 +417,6 @@ paths:
$ref: ./handlers/workflows/spec/paths/getWorkflowTags.generated.yml
put:
$ref: ./handlers/workflows/spec/paths/updateWorkflowTags.generated.yml
/workflows/{workflowId}/{workflowVersionId}:
get:
$ref: ./handlers/workflows/spec/paths/getDeprecatedWorkflowVersion.generated.yml

View File

@ -79,8 +79,6 @@ tags:
description: Operations about workflows
paths:
/workflows/{id}/{versionId}:
$ref: './handlers/workflows/spec/paths/workflows.id.versionId.yml'
/n8n-packages/export:
$ref: './handlers/n8n-packages/spec/paths/n8n-packages.export.yml'
/n8n-packages/import:

View File

@ -18,12 +18,15 @@ const configWith = ({
pruneData = true,
useWorkflowPublicationService = true,
diagnosticsEnabled = true,
historyPruneTimeHours = -1,
dbType = 'postgresdb',
} = {}) =>
mock<GlobalConfig>({
executions: { pruneData },
workflows: { useWorkflowPublicationService },
diagnostics: { enabled: diagnosticsEnabled },
workflowHistory: { pruneTime: historyPruneTimeHours },
workflowHistoryCompaction: { trimmingMinimumAgeDays: 6 },
database: { type: dbType as GlobalConfig['database']['type'] },
});
@ -34,9 +37,9 @@ it('should return every main task when all features are on', async () => {
ActivityPruningTask,
LicenseRenewalTask,
WorkflowHistoryCompactionOptimizeTask,
WorkflowHistoryCompactionTrimTask,
WorkflowHistoryPruningTask,
PendingAuthorizationCleanupTask,
WorkflowHistoryCompactionTrimTask,
ExecutionPruningSoftDeleteTask,
TelemetryPulseTask,
WorkflowPublicationOutboxCleanupTask,
@ -57,6 +60,13 @@ it('should leave out execution pruning soft delete when pruning is off', async (
expect(tasks).toContain(WorkflowPublicationOutboxCleanupTask);
});
it('should leave out the history trim when the prune horizon is shorter than the trim window', async () => {
const tasks = await mainSystemTasks(configWith({ historyPruneTimeHours: 24 }));
expect(tasks).not.toContain(WorkflowHistoryCompactionTrimTask);
expect(tasks).toContain(WorkflowHistoryCompactionOptimizeTask);
});
it('should leave out outbox cleanup when the publication service is off', async () => {
const tasks = await mainSystemTasks(configWith({ useWorkflowPublicationService: false }));

View File

@ -34,6 +34,7 @@ describe('SystemTaskRunner', () => {
enabledForSystemTasks = false,
instanceRole = 'leader' as InstanceSettings['instanceRole'],
instanceType = 'main' as InstanceSettings['instanceType'],
timezone = 'UTC',
} = {}) {
const logger = mock<Logger>();
const metadata = new SystemTaskMetadata();
@ -54,7 +55,7 @@ describe('SystemTaskRunner', () => {
jobRegistrar,
systemTaskOwner,
mock<GlobalConfig>({
generic: { timezone: 'UTC' },
generic: { timezone },
scheduler: { enabledForSystemTasks },
}),
instanceSettings,
@ -865,6 +866,22 @@ describe('SystemTaskRunner', () => {
expect(dummy.runCount).toBe(1);
});
it('runs a durable cron task on its timer, in the instance timezone, while the scheduler is inactive', async () => {
dummy.placement = { scope: 'cluster', durable: true };
dummy.schedule = { kind: 'cron', cronExpression: '0 3 * * *', timezone: null };
const { runner, metadata, durableScheduler } = setup({ timezone: 'Europe/Berlin' });
metadata.register(DummySystemTask);
await initRunner(runner);
// 03:00 in Berlin is 02:00 UTC in January.
await vi.advanceTimersByTimeAsync(2 * Time.hours.toMilliseconds - 1);
expect(dummy.runCount).toBe(0);
await vi.advanceTimersByTimeAsync(1);
expect(durableScheduler.registerTaskHandler).not.toHaveBeenCalled();
expect(dummy.runCount).toBe(1);
});
it('declares a task it hands to the durable scheduler to the job owner', async () => {
dummy.placement = { scope: 'cluster', durable: true };
const { runner, metadata, systemTaskOwner } = setup(durably);

View File

@ -2,6 +2,7 @@ import type { GlobalConfig } from '@n8n/config';
import type { SystemTaskClass } from '@n8n/decorators';
import { ActivityPruningTask } from '@/services/pruning/activity-pruning.task';
import { isTrimmingEnabled } from '@/services/pruning/workflow-history-compaction.utils';
/**
* Return the main command's own system tasks, owned by no backend module.
@ -12,9 +13,6 @@ export async function mainSystemTasks(globalConfig: GlobalConfig): Promise<Syste
const { WorkflowHistoryCompactionOptimizeTask } = await import(
'@/services/pruning/workflow-history-compaction-optimize.task.js'
);
const { WorkflowHistoryCompactionTrimTask } = await import(
'@/services/pruning/workflow-history-compaction-trim.task.js'
);
const { WorkflowHistoryPruningTask } = await import(
'@/services/pruning/workflow-history-pruning.task.js'
);
@ -26,11 +24,17 @@ export async function mainSystemTasks(globalConfig: GlobalConfig): Promise<Syste
ActivityPruningTask,
LicenseRenewalTask,
WorkflowHistoryCompactionOptimizeTask,
WorkflowHistoryCompactionTrimTask,
WorkflowHistoryPruningTask,
PendingAuthorizationCleanupTask,
];
if (isTrimmingEnabled(globalConfig.workflowHistory, globalConfig.workflowHistoryCompaction)) {
const { WorkflowHistoryCompactionTrimTask } = await import(
'@/services/pruning/workflow-history-compaction-trim.task.js'
);
tasks.push(WorkflowHistoryCompactionTrimTask);
}
if (globalConfig.executions.pruneData) {
const { ExecutionPruningSoftDeleteTask } = await import(
'@/services/pruning/execution-pruning-soft-delete.task.js'

View File

@ -27,6 +27,16 @@ function findEntityFiles(dir: string): string[] {
.filter((file) => readFileSync(file, 'utf8').includes('@Entity('));
}
function findPackagedModuleEntityFiles(): string[] {
const modulesDir = path.join(REPO_ROOT, 'packages/modules');
return readdirSync(modulesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.flatMap((entry) => {
const backendSourceDir = path.join(modulesDir, entry.name, 'backend/src');
return existsSync(backendSourceDir) ? findEntityFiles(backendSourceDir) : [];
});
}
/**
* Guard: every entity that belongs to a Project must be explicitly
* accounted for in the ownership-transfer manifest, so that adding a new
@ -43,8 +53,11 @@ describe('ownership-transfer manifest', () => {
beforeAll(async () => {
// Register module entity decorators via the filesystem so that a newly
// added module entity is picked up without editing this test.
const moduleEntityFiles = findEntityFiles(path.resolve(__dirname, '../../../modules'));
// added CLI or packaged module entity is picked up without editing this test.
const moduleEntityFiles = [
...findEntityFiles(path.resolve(__dirname, '../../../modules')),
...findPackagedModuleEntityFiles(),
];
moduleEntityFileCount = moduleEntityFiles.length;
await Promise.all(moduleEntityFiles.map(async (file) => await import(file)));

View File

@ -76,7 +76,7 @@
},
{
"name": "InsightsMetadata",
"path": "packages/cli/src/modules/insights/database/entities/insights-metadata.ts",
"path": "packages/modules/insights/backend/src/insights-metadata.entity.ts",
"reason": "Denormalized analytics snapshot; historical insights keep the original project reference by design."
},
{

View File

@ -1,57 +1,29 @@
import { Time } from '@n8n/constants';
import { mock } from 'vitest-mock-extended';
import { WorkflowHistoryCompactionTrimTask } from '../workflow-history-compaction-trim.task';
import type { WorkflowHistoryCompactionService } from '../workflow-history-compaction.service';
describe('WorkflowHistoryCompactionTrimTask', () => {
let compactionService = mock<WorkflowHistoryCompactionService>();
let task = new WorkflowHistoryCompactionTrimTask(compactionService);
const compactionService = mock<WorkflowHistoryCompactionService>();
const task = new WorkflowHistoryCompactionTrimTask(compactionService);
const setService = ({ trimmingEnabled = true } = {}) => {
Object.defineProperty(compactionService, 'isTrimmingEnabled', { value: trimmingEnabled });
};
beforeEach(() => {
compactionService = mock<WorkflowHistoryCompactionService>();
task = new WorkflowHistoryCompactionTrimTask(compactionService);
});
afterEach(() => {
vi.useRealTimers();
});
it('should declare an hourly tick', () => {
it('should declare a daily cron in the instance timezone and run durably', () => {
expect(task.name).toBe('workflow-history-compaction-trim');
expect(task.schedule).toEqual({ kind: 'interval', intervalSeconds: 3600 });
expect(task.schedule).toEqual({ kind: 'cron', cronExpression: '0 3 * * *', timezone: null });
expect(task.effects).toBe('idempotent');
expect(task.placement).toEqual({ scope: 'cluster', durable: false });
expect(task.placement).toEqual({ scope: 'cluster', durable: true });
});
it('should trim at 3am server time, handing the pass its abort signal', async () => {
vi.setSystemTime(new Date(2026, 10, 10, 3, 0, 0));
setService();
it('should keep a missed occurrence claimable for an hour', () => {
expect(task.misfireGraceSeconds).toBe(Time.hours.toSeconds);
});
it('should trim on run, handing the pass its abort signal', async () => {
const { signal } = new AbortController();
await task.run(signal);
expect(compactionService.trimLongRunningHistories).toHaveBeenCalledExactlyOnceWith(signal);
});
it('should not trim outside of 3am server time', async () => {
vi.setSystemTime(new Date(2026, 10, 10, 5, 0, 0));
setService();
await task.run(new AbortController().signal);
expect(compactionService.trimLongRunningHistories).not.toHaveBeenCalled();
});
it('should not trim when the prune horizon is shorter than the trim window', async () => {
vi.setSystemTime(new Date(2026, 10, 10, 3, 0, 0));
setService({ trimmingEnabled: false });
await task.run(new AbortController().signal);
expect(compactionService.trimLongRunningHistories).not.toHaveBeenCalled();
});
});

View File

@ -6,10 +6,7 @@ import type { DbConnection, WorkflowHistoryRepository } from '@n8n/db';
import type { InstanceSettings } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import {
getCompactionWindowDeltas,
WorkflowHistoryCompactionService,
} from '../workflow-history-compaction.service';
import { WorkflowHistoryCompactionService } from '../workflow-history-compaction.service';
describe('WorkflowHistoryCompactionService', () => {
const dbConnection = mock<DbConnection>({
@ -26,37 +23,21 @@ describe('WorkflowHistoryCompactionService', () => {
skipOnStartUp: false,
});
const globalConfig = mock<GlobalConfig>({
generic: { timezone: 'Europe/Berlin' },
workflowHistory: {
pruneTime: -1,
},
});
beforeEach(() => {
// Set the system to a time that isn't 3 AM to avoid hitting the "trim once a day" window
const mockDate = new Date(2026, 10, 10, 1, 0, 0);
vi.setSystemTime(mockDate);
// The window tests compare exact dates derived from `Date.now()`.
vi.setSystemTime(new Date(2026, 10, 10, 1, 0, 0));
});
afterEach(() => {
vi.useRealTimers();
});
describe('getCompactionWindowDeltas', () => {
it('should offset the start delta by the time window and leave the end delta at the minimum age', () => {
expect(getCompactionWindowDeltas(24, 2, Time.hours.toMilliseconds)).toEqual({
startDelta: 26 * Time.hours.toMilliseconds,
endDelta: 24 * Time.hours.toMilliseconds,
});
});
it('should return equal deltas for an empty time window', () => {
expect(getCompactionWindowDeltas(7, 0, Time.days.toMilliseconds)).toEqual({
startDelta: 7 * Time.days.toMilliseconds,
endDelta: 7 * Time.days.toMilliseconds,
});
});
});
describe('init', () => {
it('should start compacting on main instance that is the leader', () => {
const compactingService = new WorkflowHistoryCompactionService(
@ -95,7 +76,7 @@ describe('WorkflowHistoryCompactionService', () => {
it('should skip trimming if pruneTime < trimAge', () => {
const compactingService = new WorkflowHistoryCompactionService(
config,
{ ...config, trimOnStartUp: true },
{ ...globalConfig, workflowHistory: { pruneTime: 24 } },
mockLogger(),
mock<InstanceSettings>({ isLeader: true, instanceType: 'main', isMultiMain: true }),
@ -110,7 +91,6 @@ describe('WorkflowHistoryCompactionService', () => {
compactingService.runStartupCompaction();
expect(compactingService.isTrimmingEnabled).toBe(false);
expect(trimLongRunningHistoriesSpy).not.toBeCalled();
});
it('should not skip trimming if pruneTime > trimAge', () => {
@ -207,32 +187,6 @@ describe('WorkflowHistoryCompactionService', () => {
expect(optimizeHistoriesSpy).toHaveBeenCalled();
});
it('should trim if triggered at 3 AM with trimOnStartUp as false', () => {
const mockDate = new Date(2026, 10, 10, 3, 0, 0);
vi.setSystemTime(mockDate);
const compactingService = new WorkflowHistoryCompactionService(
{ ...config, trimOnStartUp: false },
globalConfig,
mockLogger(),
mock<InstanceSettings>({ isLeader: true, instanceType: 'main', isMultiMain: true }),
dbConnection,
mock(),
mock<EventService>(),
);
vi.spyOn(compactingService, 'optimizeHistories').mockImplementation((() => {}) as never);
const trimLongRunningHistoriesSpy = vi
.spyOn(compactingService, 'trimLongRunningHistories')
.mockImplementation((() => {}) as never);
compactingService.runStartupCompaction();
expect(trimLongRunningHistoriesSpy).toHaveBeenCalled();
vi.useRealTimers();
});
describe('compactHistories', () => {
const createService = (
workflowHistoryRepository: WorkflowHistoryRepository,
@ -276,20 +230,98 @@ describe('WorkflowHistoryCompactionService', () => {
expect(workflowHistoryRepository.pruneHistory).toHaveBeenCalledOnce();
});
it('should trim over the window between trimmingMinimumAge and the time window', async () => {
it('should trim over the window between trimmingMinimumAge and the time window, anchored to the start of the day in the instance timezone', async () => {
// 03:00 UTC on 10 November is 04:00 in Berlin, whose day started at 23:00 UTC the day before.
vi.setSystemTime(new Date('2026-11-10T03:00:00.000Z'));
const startOfDay = new Date('2026-11-09T23:00:00.000Z');
const workflowHistoryRepository = mock<WorkflowHistoryRepository>();
workflowHistoryRepository.getWorkflowIdsInRange.mockResolvedValue([]);
const { compactingService } = createService(workflowHistoryRepository);
await compactingService['trimLongRunningHistories'](new AbortController().signal);
const now = Date.now();
expect(workflowHistoryRepository.getWorkflowIdsInRange).toHaveBeenCalledWith(
new Date(now - 9 * Time.days.toMilliseconds),
new Date(now - 7 * Time.days.toMilliseconds),
new Date(startOfDay.getTime() - 9 * Time.days.toMilliseconds),
new Date(startOfDay.getTime() - 7 * Time.days.toMilliseconds),
);
});
it('should read the same trim window for every pass of one day', async () => {
const workflowHistoryRepository = mock<WorkflowHistoryRepository>();
workflowHistoryRepository.getWorkflowIdsInRange.mockResolvedValue([]);
const { compactingService } = createService(workflowHistoryRepository);
vi.setSystemTime(new Date('2026-11-10T03:00:00.000Z'));
await compactingService['trimLongRunningHistories'](new AbortController().signal);
vi.setSystemTime(new Date('2026-11-10T22:59:59.999Z'));
await compactingService['trimLongRunningHistories'](new AbortController().signal);
const [first, second] = workflowHistoryRepository.getWorkflowIdsInRange.mock.calls;
expect(second).toEqual(first);
});
it('should move the trim window by one day at midnight in the instance timezone', async () => {
const workflowHistoryRepository = mock<WorkflowHistoryRepository>();
workflowHistoryRepository.getWorkflowIdsInRange.mockResolvedValue([]);
const { compactingService } = createService(workflowHistoryRepository);
vi.setSystemTime(new Date('2026-11-10T22:59:59.999Z'));
await compactingService['trimLongRunningHistories'](new AbortController().signal);
vi.setSystemTime(new Date('2026-11-10T23:00:00.000Z'));
await compactingService['trimLongRunningHistories'](new AbortController().signal);
const [[firstStart, firstEnd], [secondStart, secondEnd]] =
workflowHistoryRepository.getWorkflowIdsInRange.mock.calls;
expect(secondStart.getTime() - firstStart.getTime()).toBe(Time.days.toMilliseconds);
expect(secondEnd.getTime() - firstEnd.getTime()).toBe(Time.days.toMilliseconds);
});
it.each([
// At 03:00 UTC it is still the day before in New York.
['America/New_York', '2026-11-10T03:00:00.000Z', '2026-11-09T05:00:00.000Z'],
// 25 October 2026 has 25 hours in Berlin; the day still starts at its own midnight.
['Europe/Berlin', '2026-10-25T03:00:00.000Z', '2026-10-24T22:00:00.000Z'],
])(
'should anchor the trim window to the start of the day in %s',
async (timezone, now, startOfDay) => {
vi.setSystemTime(new Date(now));
const workflowHistoryRepository = mock<WorkflowHistoryRepository>();
workflowHistoryRepository.getWorkflowIdsInRange.mockResolvedValue([]);
const compactingService = new WorkflowHistoryCompactionService(
config,
mock<GlobalConfig>({ generic: { timezone }, workflowHistory: { pruneTime: -1 } }),
mockLogger(),
mock<InstanceSettings>({ isLeader: true, instanceType: 'main', isMultiMain: true }),
dbConnection,
workflowHistoryRepository,
mock<EventService>(),
);
await compactingService['trimLongRunningHistories'](new AbortController().signal);
const anchorMs = new Date(startOfDay).getTime();
expect(workflowHistoryRepository.getWorkflowIdsInRange).toHaveBeenCalledWith(
new Date(anchorMs - 9 * Time.days.toMilliseconds),
new Date(anchorMs - 7 * Time.days.toMilliseconds),
);
},
);
it('should keep the optimize window on the clock while the trim window is anchored', async () => {
vi.setSystemTime(new Date('2026-11-10T03:00:00.000Z'));
const workflowHistoryRepository = mock<WorkflowHistoryRepository>();
workflowHistoryRepository.getWorkflowIdsInRange.mockResolvedValue([]);
const { compactingService } = createService(workflowHistoryRepository);
await compactingService['optimizeHistories'](new AbortController().signal);
await compactingService['trimLongRunningHistories'](new AbortController().signal);
const [[, optimizeEnd], [, trimEnd]] =
workflowHistoryRepository.getWorkflowIdsInRange.mock.calls;
expect(optimizeEnd).toEqual(new Date('2026-11-09T03:00:00.000Z'));
expect(trimEnd).toEqual(new Date('2026-11-02T23:00:00.000Z'));
});
it('should not emit telemetry when no workflows are in range', async () => {
const workflowHistoryRepository = mock<WorkflowHistoryRepository>();
workflowHistoryRepository.getWorkflowIdsInRange.mockResolvedValue([]);
@ -410,30 +442,4 @@ describe('WorkflowHistoryCompactionService', () => {
expect(signal.aborted).toBe(true);
});
});
it('should not trim if triggered outside of 3 AM with trimOnStartUp as false', () => {
const mockDate = new Date(2026, 10, 10, 5, 0, 0);
vi.setSystemTime(mockDate);
const compactingService = new WorkflowHistoryCompactionService(
{ ...config, trimOnStartUp: false },
globalConfig,
mockLogger(),
mock<InstanceSettings>({ isLeader: true, instanceType: 'main', isMultiMain: true }),
dbConnection,
mock(),
mock<EventService>(),
);
vi.spyOn(compactingService, 'optimizeHistories').mockImplementation((() => {}) as never);
const trimLongRunningHistoriesSpy = vi
.spyOn(compactingService, 'trimLongRunningHistories')
.mockImplementation((() => {}) as never);
compactingService.runStartupCompaction();
expect(trimLongRunningHistoriesSpy).not.toHaveBeenCalled();
vi.useRealTimers();
});
});

View File

@ -0,0 +1,19 @@
import { Time } from '@n8n/constants';
import { getCompactionWindowDeltas } from '../workflow-history-compaction.utils';
describe('getCompactionWindowDeltas', () => {
it('should offset the start delta by the time window and leave the end delta at the minimum age', () => {
expect(getCompactionWindowDeltas(24, 2, Time.hours.toMilliseconds)).toEqual({
startDelta: 26 * Time.hours.toMilliseconds,
endDelta: 24 * Time.hours.toMilliseconds,
});
});
it('should return equal deltas for an empty time window', () => {
expect(getCompactionWindowDeltas(7, 0, Time.days.toMilliseconds)).toEqual({
startDelta: 7 * Time.days.toMilliseconds,
endDelta: 7 * Time.days.toMilliseconds,
});
});
});

View File

@ -1,5 +1,5 @@
import { Time } from '@n8n/constants';
import { intervalFromSeconds, SystemTask } from '@n8n/decorators';
import { SystemTask } from '@n8n/decorators';
import type { SystemTaskEffects, SystemTaskPlacement, SystemTaskSchedule } from '@n8n/decorators';
import { WorkflowHistoryCompactionService } from './workflow-history-compaction.service';
@ -12,19 +12,24 @@ import { WorkflowHistoryCompactionService } from './workflow-history-compaction.
export class WorkflowHistoryCompactionTrimTask implements SystemTask {
readonly name = 'workflow-history-compaction-trim';
// Hourly tick with a 3am gate in run() mirrors the legacy timer, which had
// to survive leader changes. CAT-4173 replaces this with a cron schedule.
readonly schedule: SystemTaskSchedule = intervalFromSeconds(Time.hours.toSeconds);
readonly schedule: SystemTaskSchedule = {
kind: 'cron',
cronExpression: '0 3 * * *',
// `null` resolves to `GENERIC_TIMEZONE` at run time.
timezone: null,
};
readonly effects: SystemTaskEffects = 'idempotent';
readonly placement: SystemTaskPlacement = { scope: 'cluster', durable: false };
// A late run on the same day reads the same window, so an hour carries the
// occurrence across a restart at 03:00 instead of losing the day's trim.
readonly misfireGraceSeconds = Time.hours.toSeconds;
readonly placement: SystemTaskPlacement = { scope: 'cluster', durable: true };
constructor(private readonly compactionService: WorkflowHistoryCompactionService) {}
async run(signal: AbortSignal): Promise<void> {
if (!this.compactionService.isTrimmingEnabled) return;
if (new Date().getHours() !== 3) return;
await this.compactionService.trimLongRunningHistories(signal);
}
}

View File

@ -8,14 +8,13 @@ import { Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import { sleep } from '@n8n/utils/sleep';
import { DateTime } from 'luxon';
import { DiffMetaData, DiffRule, RULES, SKIP_RULES } from 'n8n-workflow';
import { strict } from 'node:assert';
import { RelayEventMap } from '@/events/maps/relay.event-map';
export function getCompactionWindowDeltas(minimumAge: number, timeWindow: number, unitMs: number) {
return { startDelta: (minimumAge + timeWindow) * unitMs, endDelta: minimumAge * unitMs };
}
import { getCompactionWindowDeltas, isTrimmingEnabled } from './workflow-history-compaction.utils';
/**
* Responsible for compacting auto saved workflow history entries in the database.
@ -77,15 +76,6 @@ export class WorkflowHistoryCompactionService {
return this.instanceSettings.instanceType === 'main' && this.instanceSettings.isLeader;
}
/** Whether trimming may run at all: a prune horizon shorter than the trim window makes trimming pointless. */
get isTrimmingEnabled() {
return (
this.globalConfig.workflowHistory.pruneTime === -1 ||
this.globalConfig.workflowHistory.pruneTime * Time.hours.toMilliseconds >=
this.config.trimmingMinimumAgeDays * Time.days.toMilliseconds
);
}
// One-shot catch-up pass on startup and on leader change, so a gap between
// leaders is compacted without waiting a full task interval. Not `runOnTakeover`
// on the tasks: `trimOnStartUp` forces a trim only here, and a task run cannot
@ -101,8 +91,10 @@ export class WorkflowHistoryCompactionService {
void this.optimizeHistories(signal);
if (!this.isTrimmingEnabled) return;
if (this.config.trimOnStartUp || new Date().getHours() === 3) {
if (
this.config.trimOnStartUp &&
isTrimmingEnabled(this.globalConfig.workflowHistory, this.config)
) {
void this.trimLongRunningHistories(signal);
}
}
@ -134,6 +126,7 @@ export class WorkflowHistoryCompactionService {
try {
await this.compactHistories(
this.startOfToday(),
startDelta,
endDelta,
[
@ -175,6 +168,7 @@ export class WorkflowHistoryCompactionService {
try {
await this.compactHistories(
new Date(),
startDelta,
endDelta,
[RULES.mergeAdditiveChanges],
@ -186,7 +180,14 @@ export class WorkflowHistoryCompactionService {
}
}
/** Every trim pass of one day reads the same window, so a repeated pass finds nothing new. */
private startOfToday(): Date {
return DateTime.now().setZone(this.globalConfig.generic.timezone).startOf('day').toJSDate();
}
/** The window ends `endDeltaMs` before `anchor` and starts `startDeltaMs` before it. */
private async compactHistories(
anchor: Date,
startDeltaMs: number,
endDeltaMs: number,
rules: DiffRule[],
@ -196,8 +197,8 @@ export class WorkflowHistoryCompactionService {
): Promise<void> {
const compactionStartTime = Date.now();
const startDate = new Date(compactionStartTime - startDeltaMs);
const endDate = new Date(compactionStartTime - endDeltaMs);
const startDate = new Date(anchor.getTime() - startDeltaMs);
const endDate = new Date(anchor.getTime() - endDeltaMs);
const startIso = startDate.toISOString();
const endIso = endDate.toISOString();

View File

@ -0,0 +1,18 @@
import type { GlobalConfig, WorkflowHistoryCompactionConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
export function getCompactionWindowDeltas(minimumAge: number, timeWindow: number, unitMs: number) {
return { startDelta: (minimumAge + timeWindow) * unitMs, endDelta: minimumAge * unitMs };
}
/** A prune horizon shorter than the trim window makes trimming pointless. */
export function isTrimmingEnabled(
workflowHistory: GlobalConfig['workflowHistory'],
compaction: WorkflowHistoryCompactionConfig,
): boolean {
return (
workflowHistory.pruneTime === -1 ||
workflowHistory.pruneTime * Time.hours.toMilliseconds >=
compaction.trimmingMinimumAgeDays * Time.days.toMilliseconds
);
}

View File

@ -24,10 +24,12 @@ export const TOKEN_PURPOSES = {
teamsArmTemplate: 'n8n:teams-arm-template',
publicApiKey: 'public-api',
mcpApiKey: 'mcp-server-api',
scimApiKey: 'scim-api',
} as const satisfies Record<string, string> & {
// Keep these two in step with the persisted `api_key.audience` values.
// Keep these three in step with the persisted `api_key.audience` values.
publicApiKey: ApiKeyAudience;
mcpApiKey: ApiKeyAudience;
scimApiKey: ApiKeyAudience;
};
export type TokenPurpose = keyof typeof TOKEN_PURPOSES;
@ -51,6 +53,7 @@ export type TokenPurpose = keyof typeof TOKEN_PURPOSES;
export const API_KEY_PURPOSES = {
'public-api': 'publicApiKey',
'mcp-server-api': 'mcpApiKey',
'scim-api': 'scimApiKey',
} as const satisfies Record<ApiKeyAudience, TokenPurpose>;
const isNonEmptyString = (value: unknown): value is string =>

View File

@ -2,7 +2,6 @@ import {
createTeamProject,
createWorkflow,
createWorkflowHistory,
createWorkflowWithHistory,
testDb,
} from '@n8n/backend-test-utils';
import {
@ -27,6 +26,12 @@ describe('WorkflowHistoryRepository', () => {
const alwaysMergeRule = () => true;
// Rows written in the same millisecond have no insertion order, so every version gets its own second.
const secondsAgo = (seconds: number) => new Date(Date.now() - seconds * 1_000);
// A readable version id of the 36 characters a real one has. Postgres pads a shorter workflow versionId.
const vid = (prefix: string) => prefix.padEnd(36, '0');
beforeAll(async () => {
await testDb.init();
});
@ -53,25 +58,31 @@ describe('WorkflowHistoryRepository', () => {
const id1 = uuid();
const id2 = uuid();
const workflow = await createWorkflowWithHistory({
versionId: id1,
nodes: [{ ...testNode1, parameters: { a: 'a' } }],
});
await createWorkflowHistory({
...workflow,
versionId: uuid(),
nodes: [{ ...testNode1, parameters: { a: 'ab' } }],
});
await createWorkflowHistory({
...workflow,
versionId: uuid(),
nodes: [{ ...testNode1, parameters: { a: 'abc' } }],
});
await createWorkflowHistory({
...workflow,
versionId: id2,
nodes: [{ ...testNode1, parameters: { a: 'abcd' } }],
});
const workflow = await createWorkflow({ versionId: id2 });
await createWorkflowHistory(
{ ...workflow, versionId: id1, nodes: [{ ...testNode1, parameters: { a: 'a' } }] },
undefined,
undefined,
{ createdAt: secondsAgo(4) },
);
await createWorkflowHistory(
{ ...workflow, versionId: uuid(), nodes: [{ ...testNode1, parameters: { a: 'ab' } }] },
undefined,
undefined,
{ createdAt: secondsAgo(3) },
);
await createWorkflowHistory(
{ ...workflow, versionId: uuid(), nodes: [{ ...testNode1, parameters: { a: 'abc' } }] },
undefined,
undefined,
{ createdAt: secondsAgo(2) },
);
await createWorkflowHistory(
{ ...workflow, versionId: id2, nodes: [{ ...testNode1, parameters: { a: 'abcd' } }] },
undefined,
undefined,
{ createdAt: secondsAgo(1) },
);
// ACT
const repository = Container.get(WorkflowHistoryRepository);
@ -128,25 +139,31 @@ describe('WorkflowHistoryRepository', () => {
const id1 = uuid();
const id2 = uuid();
const workflow = await createWorkflowWithHistory({
versionId: id1,
nodes: [{ ...testNode1, parameters: { a: 'abcde' } }],
});
await createWorkflowHistory({
...workflow,
versionId: uuid(),
nodes: [{ ...testNode1, parameters: { a: 'ab' } }],
});
await createWorkflowHistory({
...workflow,
versionId: uuid(),
nodes: [{ ...testNode1, parameters: { a: 'abc' } }],
});
await createWorkflowHistory({
...workflow,
versionId: id2,
nodes: [{ ...testNode1, parameters: { a: 'abcd' } }],
});
const workflow = await createWorkflow({ versionId: id2 });
await createWorkflowHistory(
{ ...workflow, versionId: id1, nodes: [{ ...testNode1, parameters: { a: 'abcde' } }] },
undefined,
undefined,
{ createdAt: secondsAgo(4) },
);
await createWorkflowHistory(
{ ...workflow, versionId: uuid(), nodes: [{ ...testNode1, parameters: { a: 'ab' } }] },
undefined,
undefined,
{ createdAt: secondsAgo(3) },
);
await createWorkflowHistory(
{ ...workflow, versionId: uuid(), nodes: [{ ...testNode1, parameters: { a: 'abc' } }] },
undefined,
undefined,
{ createdAt: secondsAgo(2) },
);
await createWorkflowHistory(
{ ...workflow, versionId: id2, nodes: [{ ...testNode1, parameters: { a: 'abcd' } }] },
undefined,
undefined,
{ createdAt: secondsAgo(1) },
);
// ACT
const repository = Container.get(WorkflowHistoryRepository);
@ -187,39 +204,37 @@ describe('WorkflowHistoryRepository', () => {
const id4 = uuid();
const id5 = uuid();
const workflow = await createWorkflowWithHistory({
versionId: id1,
nodes: [{ ...testNode1, parameters: { a: 'a' } }],
});
const workflow = await createWorkflow({ versionId: id5 });
await createWorkflowHistory(
{
...workflow,
versionId: id2,
nodes: [{ ...testNode1, parameters: { a: 'ab' } }],
},
{ ...workflow, versionId: id1, nodes: [{ ...testNode1, parameters: { a: 'a' } }] },
undefined,
undefined,
{ name: 'aVersionName' },
{ createdAt: secondsAgo(5) },
);
await createWorkflowHistory({
...workflow,
versionId: id3,
nodes: [{ ...testNode1, parameters: { a: 'abc' } }],
});
await createWorkflowHistory(
{
...workflow,
versionId: id4,
nodes: [{ ...testNode1, parameters: { a: 'abcd' } }],
},
{ ...workflow, versionId: id2, nodes: [{ ...testNode1, parameters: { a: 'ab' } }] },
undefined,
undefined,
{ name: 'aVersionName', createdAt: secondsAgo(4) },
);
await createWorkflowHistory(
{ ...workflow, versionId: id3, nodes: [{ ...testNode1, parameters: { a: 'abc' } }] },
undefined,
undefined,
{ createdAt: secondsAgo(3) },
);
await createWorkflowHistory(
{ ...workflow, versionId: id4, nodes: [{ ...testNode1, parameters: { a: 'abcd' } }] },
undefined,
{ event: 'activated' },
{ createdAt: secondsAgo(2) },
);
await createWorkflowHistory(
{ ...workflow, versionId: id5, nodes: [{ ...testNode1, parameters: { a: 'abcde' } }] },
undefined,
undefined,
{ createdAt: secondsAgo(1) },
);
await createWorkflowHistory({
...workflow,
versionId: id5,
nodes: [{ ...testNode1, parameters: { a: 'abcde' } }],
});
// ACT
const repository = Container.get(WorkflowHistoryRepository);
@ -251,6 +266,153 @@ describe('WorkflowHistoryRepository', () => {
expect(redo.deleted).toBe(0);
expect(redo.seen).toBe(3);
});
it('should not delete a version that was named after the versions were read', async () => {
const id1 = uuid();
const id2 = uuid();
const workflow = await createWorkflow({ versionId: id2, nodes: [testNode1] });
await createWorkflowHistory({ ...workflow, versionId: id1 }, undefined, undefined, {
createdAt: secondsAgo(2),
});
await createWorkflowHistory({ ...workflow, versionId: id2 }, undefined, undefined, {
createdAt: secondsAgo(1),
});
const repository = Container.get(WorkflowHistoryRepository);
const originalDelete = repository.delete.bind(repository);
const deleteSpy = vi
.spyOn(repository, 'delete')
.mockImplementationOnce(async (criteria: Parameters<typeof originalDelete>[0]) => {
await repository.update({ versionId: id1 }, { name: 'named late' });
return await originalDelete(criteria);
});
const aDayAgo = new Date(Date.now() - 24 * 60 * 60_000);
const nextDay = new Date(Date.now() + 24 * 60 * 60_000);
const { deleted } = await repository.pruneHistory(workflow.id, aDayAgo, nextDay, [
alwaysMergeRule,
]);
deleteSpy.mockRestore();
expect(deleted).toBe(0);
const history = await repository.find({ order: { createdAt: 'ASC' } });
expect(history).toEqual([
expect.objectContaining({ versionId: id1, name: 'named late' }),
expect.objectContaining({ versionId: id2 }),
]);
});
it('should not delete a version that was described after the versions were read', async () => {
const id1 = uuid();
const id2 = uuid();
const workflow = await createWorkflow({ versionId: id2, nodes: [testNode1] });
await createWorkflowHistory({ ...workflow, versionId: id1 }, undefined, undefined, {
createdAt: secondsAgo(2),
});
await createWorkflowHistory({ ...workflow, versionId: id2 }, undefined, undefined, {
createdAt: secondsAgo(1),
});
const repository = Container.get(WorkflowHistoryRepository);
const originalDelete = repository.delete.bind(repository);
const deleteSpy = vi
.spyOn(repository, 'delete')
.mockImplementationOnce(async (criteria: Parameters<typeof originalDelete>[0]) => {
await repository.update({ versionId: id1 }, { description: 'described late' });
return await originalDelete(criteria);
});
const { deleted, seen } = await repository.pruneHistory(
workflow.id,
secondsAgo(60),
new Date(),
[alwaysMergeRule],
);
deleteSpy.mockRestore();
expect({ seen, deleted }).toEqual({ seen: 2, deleted: 0 });
await expect(repository.count()).resolves.toBe(2);
});
it('should keep the greatest versionId of versions with the same createdAt, whatever their insertion order', async () => {
const createdAt = secondsAgo(30);
const workflow = await createWorkflow({ versionId: vid('v-c'), nodes: [testNode1] });
for (const versionId of [vid('v-b'), vid('v-c'), vid('v-a')]) {
await createWorkflowHistory({ ...workflow, versionId }, undefined, undefined, {
createdAt,
});
}
const repository = Container.get(WorkflowHistoryRepository);
const { deleted } = await repository.pruneHistory(workflow.id, secondsAgo(60), new Date(), [
alwaysMergeRule,
]);
expect(deleted).toBe(2);
const history = await repository.find();
expect(history).toEqual([expect.objectContaining({ versionId: vid('v-c') })]);
});
it('should keep the current version of the workflow when another version shares its createdAt and sorts after it', async () => {
const createdAt = secondsAgo(30);
const workflow = await createWorkflow({ versionId: vid('v-a'), nodes: [testNode1] });
for (const versionId of [vid('v-a'), vid('v-b')]) {
await createWorkflowHistory({ ...workflow, versionId }, undefined, undefined, {
createdAt,
});
}
const repository = Container.get(WorkflowHistoryRepository);
const { deleted } = await repository.pruneHistory(workflow.id, secondsAgo(60), new Date(), [
alwaysMergeRule,
]);
expect(deleted).toBe(0);
const remaining = (await repository.find()).map((v) => v.versionId).sort();
expect(remaining).toEqual([vid('v-a'), vid('v-b')]);
});
it('should keep the current version of the workflow when a newer unnamed version follows it', async () => {
const workflow = await createWorkflow({ versionId: vid('v-current'), nodes: [testNode1] });
await createWorkflowHistory(
{ ...workflow, versionId: vid('v-current') },
undefined,
undefined,
{
createdAt: secondsAgo(2),
},
);
await createWorkflowHistory(
{ ...workflow, versionId: vid('v-later') },
undefined,
undefined,
{
createdAt: secondsAgo(1),
},
);
const repository = Container.get(WorkflowHistoryRepository);
const { deleted } = await repository.pruneHistory(workflow.id, secondsAgo(60), new Date(), [
alwaysMergeRule,
]);
expect(deleted).toBe(0);
await expect(repository.count()).resolves.toBe(2);
});
it('should order a version with the same createdAt before a newer one, whatever its versionId', async () => {
const workflow = await createWorkflow({ versionId: vid('v-a'), nodes: [testNode1] });
await createWorkflowHistory({ ...workflow, versionId: vid('v-z') }, undefined, undefined, {
createdAt: secondsAgo(2),
});
await createWorkflowHistory({ ...workflow, versionId: vid('v-a') }, undefined, undefined, {
createdAt: secondsAgo(1),
});
const repository = Container.get(WorkflowHistoryRepository);
await repository.pruneHistory(workflow.id, secondsAgo(60), new Date(), [alwaysMergeRule]);
const history = await repository.find();
expect(history).toEqual([expect.objectContaining({ versionId: vid('v-a') })]);
});
});
describe('deleteEarlierThanExceptCurrentAndActive', () => {
// Happy path: proves the setup deletes old, unreferenced versions while

Some files were not shown because too many files have changed in this diff Show More