mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
Merge remote-tracking branch 'origin/cat-4471-add-safe-internal-regex' into cat-4471-migrate-safe-regex-callsites
This commit is contained in:
commit
8576b2705c
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
2
.github/test-metrics/e2e-impact-map.json
vendored
2
.github/test-metrics/e2e-impact-map.json
vendored
File diff suppressed because one or more lines are too long
8
.github/workflows/ci-pull-requests.yml
vendored
8
.github/workflows/ci-pull-requests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
7
.github/workflows/test-db-reusable.yml
vendored
7
.github/workflows/test-db-reusable.yml
vendored
|
|
@ -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
19
OWNERS
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
1
packages/@n8n/api-types/src/dto/scim/index.ts
Normal file
1
packages/@n8n/api-types/src/dto/scim/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ScimConfigPatchDto } from './scim-config.dto';
|
||||
6
packages/@n8n/api-types/src/dto/scim/scim-config.dto.ts
Normal file
6
packages/@n8n/api-types/src/dto/scim/scim-config.dto.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class ScimConfigPatchDto extends Z.class({
|
||||
enabled: z.boolean(),
|
||||
}) {}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export class ModuleRegistry {
|
|||
'workflow-reviews',
|
||||
'instance-ai',
|
||||
'agents',
|
||||
'scim',
|
||||
];
|
||||
|
||||
private readonly activeModules: string[] = [];
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'.",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } }],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
|||
'orchestration:read',
|
||||
'orchestration:manage',
|
||||
'saml:manage',
|
||||
'scim:manage',
|
||||
'securityAudit:generate',
|
||||
'securitySettings:manage',
|
||||
'sourceControl:pull',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
83
packages/cli/src/modules/scim/scim-auth.middleware.ts
Normal file
83
packages/cli/src/modules/scim/scim-auth.middleware.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
88
packages/cli/src/modules/scim/scim-discovery.controller.ts
Normal file
88
packages/cli/src/modules/scim/scim-discovery.controller.ts
Normal 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: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
31
packages/cli/src/modules/scim/scim-route.ts
Normal file
31
packages/cli/src/modules/scim/scim-route.ts
Normal 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),
|
||||
};
|
||||
40
packages/cli/src/modules/scim/scim-settings.service.ts
Normal file
40
packages/cli/src/modules/scim/scim-settings.service.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
110
packages/cli/src/modules/scim/scim-token.controller.ts
Normal file
110
packages/cli/src/modules/scim/scim-token.controller.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
120
packages/cli/src/modules/scim/scim-token.service.ts
Normal file
120
packages/cli/src/modules/scim/scim-token.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
14
packages/cli/src/modules/scim/scim.config.ts
Normal file
14
packages/cli/src/modules/scim/scim.config.ts
Normal 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;
|
||||
}
|
||||
30
packages/cli/src/modules/scim/scim.module.ts
Normal file
30
packages/cli/src/modules/scim/scim.module.ts
Normal 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() {}
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
{},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue
Block a user