feat(core): Add the trusted sources management API (no-changelog) (#40975)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andreas Fitzek 2026-10-13 18:24:34 +00:00 committed by GitHub
parent 2019ea5593
commit 5da4d19e50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1223 additions and 18 deletions

View File

@ -42,7 +42,8 @@
"n8n-workflow": "workspace:*",
"validator": "catalog:",
"xss": "catalog:",
"@n8n/permissions": "workspace:*"
"@n8n/permissions": "workspace:*",
"@n8n/inbound-auth": "workspace:*"
},
"peerDependencies": {
"zod": "catalog:"

View File

@ -1,6 +1 @@
export interface ConsentUiHints {
/** design-system icon name (updatedIconSet) for the consent logo tile; omit to fall back to the generic mcp icon */
icon?: string;
/** noun key inserted into first-party consent copy, e.g. 'form' (later 'webhook'); resolved to a localized word on the frontend */
consentType?: string;
}
export type { ConsentUiHints } from '@n8n/inbound-auth';

View File

@ -793,3 +793,5 @@ export {
} from './promotions/promotion-operations.dto';
export { ScimConfigPatchDto } from './scim';
export { CreateTrustedSourceDto } from './trusted-source/create-trusted-source.dto';
export { UpdateTrustedSourceDto } from './trusted-source/update-trusted-source.dto';

View File

@ -0,0 +1,72 @@
import { CreateTrustedSourceDto } from '../create-trusted-source.dto';
const config = {
version: 1,
authentication: { type: 'oauth2' },
surfaces: { 'public-api': {} },
};
const request = (overrides: Record<string, unknown> = {}) => ({
name: 'Acme',
issuer: 'https://acme.example.com',
config,
...overrides,
});
describe('CreateTrustedSourceDto', () => {
test('parses a minimal request and fills the config defaults', () => {
const result = CreateTrustedSourceDto.safeParse(request());
expect(result.success).toBe(true);
expect(result.data?.config.identity.subject).toBe('binding');
});
test('keeps the path of an issue raised by the config schema', () => {
const result = CreateTrustedSourceDto.safeParse(
request({ config: { ...config, identity: { subject: 'n8n-user-id' } } }),
);
expect(result.success).toBe(false);
expect(result.error?.issues[0]).toMatchObject({ path: ['config', 'identity', 'subject'] });
});
test.each([
{ name: 'at the top level', config: { ...config, typo: true }, path: ['config'] },
{
name: 'named like a prototype member',
config: { ...config, constructor: true },
path: ['config'],
key: 'constructor',
},
{
name: 'in a nested block',
config: { ...config, identity: { typo: true } },
path: ['config', 'identity'],
},
{
name: 'in an array item',
config: {
...config,
identity: {
roleMapping: {
mode: 'continuous',
fallbackInstanceRole: 'global:member',
instanceRoleRules: [
{ id: 'r1', expression: 'true', role: 'global:admin', enabled: true, typo: true },
],
},
},
},
path: ['config', 'identity', 'roleMapping', 'instanceRoleRules', 0],
},
])('refuses an unknown config key $name', ({ config, path, key = 'typo' }) => {
const result = CreateTrustedSourceDto.safeParse(request({ config }));
expect(result.success).toBe(false);
expect(result.error?.issues[0]).toMatchObject({
code: 'unrecognized_keys',
keys: [key],
path,
});
});
});

View File

@ -0,0 +1,63 @@
import { HttpsUrl, trustedSourceConfigSchemaFor } from '@n8n/inbound-auth';
import { z } from 'zod';
import { Z } from '../../zod-class';
export const trustedSourceNameSchema = z.string().trim().min(1).max(128);
// Discovery fetches the issuer's metadata and refuses http, so the DTO refuses it up front.
export const trustedSourceIssuerSchema = HttpsUrl;
// Admin rows only: the system row has no write route.
const adminConfigSchema = trustedSourceConfigSchemaFor('admin');
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** The first input key that parsing dropped, as its parent path plus the key. */
function droppedKey(
input: unknown,
parsed: unknown,
path: Array<string | number> = [],
): { path: Array<string | number>; key: string } | undefined {
if (Array.isArray(input) && Array.isArray(parsed)) {
for (const [index, item] of input.entries()) {
const hit = droppedKey(item, parsed[index], [...path, index]);
if (hit) return hit;
}
return undefined;
}
if (!isRecord(input) || !isRecord(parsed)) return undefined;
for (const key of Object.keys(input)) {
if (!Object.hasOwn(parsed, key)) return { path, key };
const hit = droppedKey(input[key], parsed[key], [...path, key]);
if (hit) return hit;
}
return undefined;
}
// The shared schema strips unknown keys so an older process tolerates a newer document. The write
// path refuses them instead, so a misspelled setting cannot pass as its default.
export const adminTrustedSourceConfigSchema = z.unknown().transform((input, ctx) => {
const result = adminConfigSchema.safeParse(input);
if (!result.success) {
for (const issue of result.error.issues) ctx.addIssue(issue);
return z.NEVER;
}
const dropped = droppedKey(input, result.data);
if (dropped) {
ctx.addIssue({
code: z.ZodIssueCode.unrecognized_keys,
keys: [dropped.key],
path: dropped.path,
message: `Unrecognized key in object: '${dropped.key}'`,
});
return z.NEVER;
}
return result.data;
});
export class CreateTrustedSourceDto extends Z.class({
name: trustedSourceNameSchema,
issuer: trustedSourceIssuerSchema,
config: adminTrustedSourceConfigSchema,
}) {}

View File

@ -0,0 +1,16 @@
import { z } from 'zod';
import {
adminTrustedSourceConfigSchema,
trustedSourceIssuerSchema,
trustedSourceNameSchema,
} from './create-trusted-source.dto';
import { Z } from '../../zod-class';
export class UpdateTrustedSourceDto extends Z.class({
name: trustedSourceNameSchema.optional(),
issuer: trustedSourceIssuerSchema.optional(),
config: adminTrustedSourceConfigSchema.optional(),
/** Drops every (subject, user) binding of the source. Set it when the issuer moves to another IdP. */
clearBindings: z.boolean().optional(),
}) {}

View File

@ -7,6 +7,7 @@
"references": [
{ "path": "../../workflow/tsconfig.build.esm.json" },
{ "path": "../config/tsconfig.build.json" },
{ "path": "../permissions/tsconfig.build.json" }
{ "path": "../permissions/tsconfig.build.json" },
{ "path": "../inbound-auth/tsconfig.build.json" }
]
}

View File

@ -59,6 +59,7 @@ export const LICENSE_FEATURES = {
OTEL_CUSTOM_SPAN_ATTRIBUTES: 'feat:otel:customSpanAttributes',
WORKFLOW_REVIEWS: 'feat:workflowReviews',
WORKER_POOLS: 'feat:workerPools',
TRUSTED_SOURCE_MANAGE: 'feat:trustedSourceManage',
} as const;
export const LICENSE_QUOTAS = {

View File

@ -23,7 +23,6 @@
"dist/**/*"
],
"dependencies": {
"@n8n/api-types": "workspace:*",
"@n8n/permissions": "workspace:*",
"n8n-workflow": "workspace:*"
},

View File

@ -1,4 +1,3 @@
import type { ConsentUiHints } from '@n8n/api-types';
import type { AuthPrincipal, SecurityContext } from '@n8n/permissions';
import type { OAuthResourceGrant } from 'n8n-workflow';
@ -9,6 +8,13 @@ import type { SurfaceId } from './trusted-source-config';
/** What a protected resource reads from the user. The `User` entity satisfies it. */
export type ResourceUser = AuthPrincipal & { id: string; createdAt: Date };
export interface ConsentUiHints {
/** design-system icon name (updatedIconSet) for the consent logo tile; omit to fall back to the generic mcp icon */
icon?: string;
/** noun key inserted into first-party consent copy, e.g. 'form' (later 'webhook'); resolved to a localized word on the frontend */
consentType?: string;
}
/**
* Descriptor for an OAuth 2.1 protected resource served by this instance.
*

View File

@ -38,7 +38,7 @@ export const ManagedBySchema = z.enum(['system', 'admin']);
export type ManagedBy = z.infer<typeof ManagedBySchema>;
// An admin-supplied endpoint is fetched server-side, so it must be https.
const HttpsUrl = z
export const HttpsUrl = z
.string()
.url()
.refine((url) => url.startsWith('https://'), { message: 'must be an https URL' });

View File

@ -8,7 +8,6 @@
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../api-types/tsconfig.build.json" },
{ "path": "../permissions/tsconfig.build.json" },
{ "path": "../../workflow/tsconfig.build.esm.json" }
]

View File

@ -204,5 +204,10 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = `
"roleMappingRule:delete",
"roleMappingRule:list",
"otel:manage",
"trustedSource:create",
"trustedSource:read",
"trustedSource:update",
"trustedSource:delete",
"trustedSource:list",
]
`;

View File

@ -101,6 +101,7 @@ export const RESOURCES = {
instanceAi: ['message', 'manage', 'gateway', 'eval'] as const,
roleMappingRule: [...DEFAULT_OPERATIONS] as const,
otel: ['manage'] as const,
trustedSource: [...DEFAULT_OPERATIONS] as const,
} as const;
export const API_KEY_RESOURCES = {

View File

@ -192,6 +192,11 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'roleMappingRule:delete',
'roleMappingRule:list',
'otel:manage',
'trustedSource:create',
'trustedSource:read',
'trustedSource:update',
'trustedSource:delete',
'trustedSource:list',
];
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();
@ -227,6 +232,7 @@ export const GLOBAL_MEMBER_SCOPES: Scope[] = [
'credentialResolver:list',
'instanceAi:message',
'instanceAi:gateway',
'trustedSource:list',
];
export const GLOBAL_CHAT_USER_SCOPES: Scope[] = [

View File

@ -58,6 +58,7 @@ describe('permissions', () => {
roleMappingRule: {},
otel: {},
scim: {},
trustedSource: {},
});
});
it('getResourcePermissions', () => {
@ -193,6 +194,7 @@ describe('permissions', () => {
roleMappingRule: {},
otel: {},
scim: {},
trustedSource: {},
};
expect(getResourcePermissions(scopes)).toEqual(permissionRecord);

View File

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

View File

@ -171,6 +171,9 @@ export const eventNamesAudit = [
'n8n.audit.node-type-policy.document.deleted',
'n8n.audit.node-type-policy.attachments.updated',
'n8n.audit.policy.decision.blocked',
'n8n.audit.trusted-source.created',
'n8n.audit.trusted-source.updated',
'n8n.audit.trusted-source.deleted',
] as const;
// Instance MCP server events. Kept as their own list and message class because the payload

View File

@ -3543,4 +3543,38 @@ describe('LogStreamingEventRelay', () => {
);
});
});
describe('trusted source events', () => {
it.each([
['trusted-source-created', 'n8n.audit.trusted-source.created'],
['trusted-source-updated', 'n8n.audit.trusted-source.updated'],
['trusted-source-deleted', 'n8n.audit.trusted-source.deleted'],
] as const)('should log on `%s` event with redacted user', (eventName, auditEventName) => {
const event = {
user: {
id: 'user-1',
email: 'owner@n8n.io',
firstName: 'Own',
lastName: 'Er',
role: { slug: 'global:owner' },
},
sourceId: 'source-1',
issuer: 'https://idp.example.com',
};
eventService.emit(eventName, event);
expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({
eventName: auditEventName,
payload: {
userId: 'user-1',
_email: 'owner@n8n.io',
_firstName: 'Own',
_lastName: 'Er',
globalRole: 'global:owner',
sourceId: 'source-1',
issuer: 'https://idp.example.com',
},
});
});
});
});

View File

@ -1013,6 +1013,24 @@ export type RelayEventMap = {
role: string;
};
'trusted-source-created': {
user: UserLike;
sourceId: string;
issuer: string;
};
'trusted-source-updated': {
user: UserLike;
sourceId: string;
issuer: string;
};
'trusted-source-deleted': {
user: UserLike;
sourceId: string;
issuer: string;
};
// #endregion
// #region Token exchange

View File

@ -188,6 +188,9 @@ export class LogStreamingEventRelay extends EventRelay {
'role-mapping-rule-updated': (event) => this.roleMappingRuleUpdated(event),
'role-mapping-rule-deleted': (event) => this.roleMappingRuleDeleted(event),
'role-mapping-rules-bulk-deleted': (event) => this.roleMappingRulesBulkDeleted(event),
'trusted-source-created': (event) => this.trustedSourceCreated(event),
'trusted-source-updated': (event) => this.trustedSourceUpdated(event),
'trusted-source-deleted': (event) => this.trustedSourceDeleted(event),
'mcp-oauth-completed': (event) => this.mcpOauthCompleted(event),
'mcp-tool-called': (event) => this.mcpToolCalled(event),
'mcp-access-updated': (event) => this.mcpAccessUpdated(event),
@ -1600,6 +1603,34 @@ export class LogStreamingEventRelay extends EventRelay {
// #endregion
// #region Trusted sources
@Redactable()
private trustedSourceCreated({ user, ...rest }: RelayEventMap['trusted-source-created']) {
void this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.trusted-source.created',
payload: { ...user, ...rest },
});
}
@Redactable()
private trustedSourceUpdated({ user, ...rest }: RelayEventMap['trusted-source-updated']) {
void this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.trusted-source.updated',
payload: { ...user, ...rest },
});
}
@Redactable()
private trustedSourceDeleted({ user, ...rest }: RelayEventMap['trusted-source-deleted']) {
void this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.trusted-source.deleted',
payload: { ...user, ...rest },
});
}
// #endregion
// #region MCP server
private mcpOauthCompleted({

View File

@ -21,6 +21,15 @@ import { TrustedSourceDiscoveryTask } from '../trusted-source-discovery.task';
import { TrustedSourceDbGate } from '../trusted-source.gate';
import { TrustedSourceDbStore } from '../trusted-source.store';
// A mocked module's factory runs on its first import, so the first `init()` that imports the
// controller is the one that proves the gate. Keep the "loaded" case last.
const { loadController } = vi.hoisted(() => ({ loadController: vi.fn() }));
const FLAG = 'N8N_ENV_FEAT_TRUSTED_SOURCES_API_ENABLED';
vi.mock('../trusted-source.controller', () => {
loadController();
return {};
});
describe('InboundAuthCoreModule', () => {
describe('init', () => {
const dbStore = mock<TrustedSourceDbStore>();
@ -28,6 +37,8 @@ describe('InboundAuthCoreModule', () => {
const identityService = mock<TrustedSourceIdentityService>();
beforeAll(async () => {
// The shell may carry the flag; the first init must not be the one that imports the controller.
vi.stubEnv(FLAG, '');
Container.set(InstanceSettings, mock<InstanceSettings>({ instanceType: 'main' }));
Container.set(TrustedSourceDbStore, dbStore);
Container.set(TrustedSourceDbGate, dbGate);
@ -112,4 +123,41 @@ describe('InboundAuthCoreModule', () => {
'TrustedSourceIdentityEntity',
]);
});
describe('management controller', () => {
beforeEach(() => {
loadController.mockClear();
vi.stubEnv(FLAG, '');
});
afterEach(() => {
vi.unstubAllEnvs();
Container.set(InstanceSettings, mock<InstanceSettings>({ instanceType: 'main' }));
});
it('is not loaded while the flag is unset', async () => {
await new InboundAuthCoreModule().init();
expect(loadController).not.toHaveBeenCalled();
});
it.each(['webhook', 'worker'] as const)(
'is not loaded on a %s instance even with the flag',
async (instanceType) => {
vi.stubEnv(FLAG, 'true');
Container.set(InstanceSettings, mock<InstanceSettings>({ instanceType }));
await new InboundAuthCoreModule().init();
expect(loadController).not.toHaveBeenCalled();
},
);
it('is loaded on a main instance when the flag is true', async () => {
vi.stubEnv(FLAG, 'true');
await new InboundAuthCoreModule().init();
expect(loadController).toHaveBeenCalledOnce();
});
});
});

View File

@ -0,0 +1,60 @@
import { ControllerRegistryMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { TrustedSourceController } from '../trusted-source.controller';
describe('TrustedSourceController', () => {
const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata(
TrustedSourceController as never,
);
const routes = Array.from(metadata.routes.values())
.map((route) => ({
route: `${route.method.toUpperCase()} ${route.path}`,
scope: route.accessScope?.scope,
globalOnly: route.accessScope?.globalOnly,
licenseFeature: route.licenseFeature,
}))
.sort((a, b) => a.route.localeCompare(b.route));
it('is mounted at /trusted-sources', () => {
expect(metadata.basePath).toBe('/trusted-sources');
});
// Every route needs a global trustedSource scope; only the two writes that create or change a
// source are licensed, so an operator can still inspect and remove sources after a lapse.
it('gates each route with a global scope and licenses only create and update', () => {
expect(routes).toEqual([
{
route: 'DELETE /:id',
scope: 'trustedSource:delete',
globalOnly: true,
licenseFeature: undefined,
},
{ route: 'GET /', scope: 'trustedSource:list', globalOnly: true, licenseFeature: undefined },
{
route: 'GET /:id',
scope: 'trustedSource:read',
globalOnly: true,
licenseFeature: undefined,
},
{
route: 'PATCH /:id',
scope: 'trustedSource:update',
globalOnly: true,
licenseFeature: 'feat:trustedSourceManage',
},
{
route: 'POST /',
scope: 'trustedSource:create',
globalOnly: true,
licenseFeature: 'feat:trustedSourceManage',
},
{
route: 'POST /:id/check',
scope: 'trustedSource:update',
globalOnly: true,
licenseFeature: undefined,
},
]);
});
});

View File

@ -1,3 +1,4 @@
import { isEnvFeatureEnabled } from '@n8n/backend-common';
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule } from '@n8n/decorators';
import { Container } from '@n8n/di';
@ -52,6 +53,12 @@ export class InboundAuthCoreModule implements ModuleInterface {
if (Container.get(InstanceSettings).instanceType === 'main') {
const { SystemTrustedSourceSeeder } = await import('./system-trusted-source.seeder.js');
await Container.get(SystemTrustedSourceSeeder).seed();
// Ships dark. Set `N8N_ENV_FEAT_TRUSTED_SOURCES_API_ENABLED=true` on a main instance to register
// the `/trusted-sources` management routes; the license gates only create and update.
if (isEnvFeatureEnabled('N8N_ENV_FEAT_TRUSTED_SOURCES_API_ENABLED')) {
await import('./trusted-source.controller.js');
}
}
const { OAuth2AuthenticationService } = await import('./authentication.service.js');

View File

@ -0,0 +1,198 @@
import { CreateTrustedSourceDto, UpdateTrustedSourceDto } from '@n8n/api-types';
import { EventService } from '@n8n/backend-services';
import { isUniqueConstraintError, type AuthenticatedRequest } from '@n8n/db';
import {
Body,
Delete,
Get,
GlobalScope,
Licensed,
Param,
Patch,
Post,
RestController,
} from '@n8n/decorators';
import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors';
import {
deriveCapabilities,
type Capability,
type TrustedSource,
type TrustedSourceConfigInput,
type TrustedSourceConfigLatest,
} from '@n8n/inbound-auth';
import type { Response } from 'express';
import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow';
import { TrustedSourceDiscoveryService } from './trusted-source-discovery.service';
import { TrustedSourceDbStore } from './trusted-source.store';
/** What every caller may see of a source: no issuer, no config, no status. */
type TrustedSourceSummary = {
id: string;
name: string;
capabilities: Capability[];
surfaces: string[];
};
function toSummary(source: TrustedSource): TrustedSourceSummary {
return {
id: source.id,
name: source.name,
capabilities: [...deriveCapabilities(source.config, source.metadata)],
surfaces: Object.keys(source.config.surfaces),
};
}
/** The stored secret never leaves the instance; the response carries the blanking value instead. */
function redact(source: TrustedSource): TrustedSource {
const { client } = source.config.authentication;
if (client?.kind !== 'registered' || client.clientSecret === undefined) return source;
return {
...source,
config: {
...source.config,
authentication: {
...source.config.authentication,
client: { ...client, clientSecret: CREDENTIAL_BLANKING_VALUE },
},
},
};
}
/**
* The blanking value in an incoming config means "keep the stored secret", the same contract as
* credentials. Any other value replaces it; an absent key clears it.
*/
function restoreSecret(
config: TrustedSourceConfigInput,
current: TrustedSourceConfigLatest,
): TrustedSourceConfigInput {
const { client } = config.authentication;
if (client?.kind !== 'registered' || client.clientSecret !== CREDENTIAL_BLANKING_VALUE) {
return config;
}
const storedClient = current.authentication.client;
const { clientSecret: _, ...withoutSecret } = client;
const restored =
storedClient?.kind === 'registered' && storedClient.clientSecret !== undefined
? { ...withoutSecret, clientSecret: storedClient.clientSecret }
: withoutSecret;
return { ...config, authentication: { ...config.authentication, client: restored } };
}
async function wrapConflict<T>(write: () => Promise<T>): Promise<T> {
try {
return await write();
} catch (error) {
if (isUniqueConstraintError(error)) {
throw new ConflictError('A trusted source with this name or issuer already exists');
}
throw error;
}
}
@RestController('/trusted-sources')
export class TrustedSourceController {
constructor(
private readonly store: TrustedSourceDbStore,
private readonly discovery: TrustedSourceDiscoveryService,
private readonly eventService: EventService,
) {}
@Get('/')
@GlobalScope('trustedSource:list')
async list(): Promise<TrustedSourceSummary[]> {
return (await this.store.listAll()).map(toSummary);
}
@Get('/:id')
@GlobalScope('trustedSource:read')
async get(_req: AuthenticatedRequest, _res: Response, @Param('id') id: string) {
return redact(await this.require(id));
}
@Post('/')
@Licensed('feat:trustedSourceManage')
@GlobalScope('trustedSource:create')
async create(req: AuthenticatedRequest, _res: Response, @Body dto: CreateTrustedSourceDto) {
const { client } = dto.config.authentication;
// The blanking value stands for a stored secret, and a new source has none to stand for.
if (client?.kind === 'registered' && client.clientSecret === CREDENTIAL_BLANKING_VALUE) {
throw new BadRequestError(
'clientSecret must be a real secret on create, not the blanking value',
);
}
const source = await wrapConflict(async () => await this.store.create(dto));
this.eventService.emit('trusted-source-created', {
user: req.user,
sourceId: source.id,
issuer: source.issuer,
});
return redact(source);
}
@Patch('/:id')
@Licensed('feat:trustedSourceManage')
@GlobalScope('trustedSource:update')
async update(
req: AuthenticatedRequest,
_res: Response,
@Param('id') id: string,
@Body dto: UpdateTrustedSourceDto,
) {
const current = await this.require(id);
const { clearBindings = false, config, ...rest } = dto;
// An empty patch changes nothing, so it writes no row and emits no audit event.
if (
rest.name === undefined &&
rest.issuer === undefined &&
config === undefined &&
!clearBindings
) {
return redact(current);
}
await wrapConflict(
async () =>
await this.store.update(
id,
{ ...rest, ...(config && { config: restoreSecret(config, current.config) }) },
{ clearBindings },
),
);
const updated = await this.require(id);
this.eventService.emit('trusted-source-updated', {
user: req.user,
sourceId: updated.id,
issuer: updated.issuer,
});
return redact(updated);
}
@Delete('/:id')
@GlobalScope('trustedSource:delete')
async delete(req: AuthenticatedRequest, _res: Response, @Param('id') id: string) {
const source = await this.require(id);
await this.store.delete(id);
this.eventService.emit('trusted-source-deleted', {
user: req.user,
sourceId: source.id,
issuer: source.issuer,
});
}
@Post('/:id/check')
@GlobalScope('trustedSource:update')
async check(_req: AuthenticatedRequest, _res: Response, @Param('id') id: string) {
await this.require(id);
// ponytail: shares the cron lease; a check during a cron run returns the row unchanged for at
// most 60 s. A lease-free path is the upgrade if that window is reported.
await this.discovery.refresh(id);
return redact(await this.require(id));
}
private async require(id: string): Promise<TrustedSource> {
const source = await this.store.getById(id);
if (!source) throw new NotFoundError(`Trusted source "${id}" not found`);
return source;
}
}

View File

@ -93,12 +93,17 @@ export class TrustedSourceDbStore extends TrustedSourceStore {
});
}
async listBySurface(surface: SurfaceId): Promise<TrustedSource[]> {
const all =
async listAll(): Promise<TrustedSource[]> {
return (
(await this.cacheService.get(ALL_KEY, {
ttl: CACHE_TTL,
refreshFn: async () => await this.loadAll(),
})) ?? [];
})) ?? []
);
}
async listBySurface(surface: SurfaceId): Promise<TrustedSource[]> {
const all = await this.listAll();
return all.filter((source) => source.config.surfaces[surface] !== undefined);
}

View File

@ -0,0 +1,625 @@
import { CacheService, EventService, OAuthDiscoveryClient } from '@n8n/backend-services';
import { testDb } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import type { TrustedSourceConfigInput } from '@n8n/inbound-auth';
import { DataSource, type Repository } from '@n8n/typeorm';
import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow';
import type { MockInstance } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { TrustedSourceIdentityEntity } from '@/modules/inbound-auth-core/database/entities/trusted-source-identity.entity';
import { TrustedSourceEntity } from '@/modules/inbound-auth-core/database/entities/trusted-source.entity';
import { TrustedSourceDbStore } from '@/modules/inbound-auth-core/trusted-source.store';
import { createAdmin, createMember, createOwner } from '@test-integration/db/users';
import type { SuperAgentTest } from '@test-integration/types';
import * as utils from '@test-integration/utils';
const LICENSE = 'feat:trustedSourceManage';
const SYSTEM_ID = 'n8n-internal';
const ISSUER = 'https://acme.example.com';
const JWKS_URI = `${ISSUER}/.well-known/jwks.json`;
const RSA_KEY = { kid: 'k1', kty: 'RSA', use: 'sig', alg: 'RS256', n: 'AQAB', e: 'AQAB' } as const;
// The check route runs discovery through the injected client; no network in tests.
const discoveryClient = mock<OAuthDiscoveryClient>();
Container.set(OAuthDiscoveryClient, discoveryClient);
const testServer = utils.setupTestServer({
endpointGroups: ['trusted-sources'],
modules: ['inbound-auth-core'],
enabledFeatures: [LICENSE],
});
let owner: User;
let admin: User;
let member: User;
let ownerAgent: SuperAgentTest;
let adminAgent: SuperAgentTest;
let memberAgent: SuperAgentTest;
let store: TrustedSourceDbStore;
let cacheService: CacheService;
let rows: Repository<TrustedSourceEntity>;
let bindings: Repository<TrustedSourceIdentityEntity>;
let emit: MockInstance;
const minimalConfig = (): TrustedSourceConfigInput => ({
version: 1,
authentication: { type: 'oauth2' },
surfaces: { 'public-api': {} },
});
const withClient = (clientSecret?: string): TrustedSourceConfigInput => ({
...minimalConfig(),
authentication: {
type: 'oauth2',
client: { kind: 'registered', clientId: 'n8n', ...(clientSecret && { clientSecret }) },
},
});
const body = (overrides: Record<string, unknown> = {}) => ({
name: 'Acme',
issuer: ISSUER,
config: minimalConfig(),
...overrides,
});
async function createSource(overrides: Record<string, unknown> = {}): Promise<{ id: string }> {
const response = await ownerAgent.post('/trusted-sources').send(body(overrides));
expect(response.statusCode).toBe(200);
return response.body.data;
}
async function bind(sourceId: string, subject: string) {
await bindings.save(
bindings.create({
sourceId,
subject,
userId: member.id,
provenance: 'admin',
status: 'active',
lastSeenAt: null,
}),
);
}
async function storedClient(id: string) {
await cacheService.reset();
const source = await store.getById(id);
return source?.config.authentication.client;
}
beforeAll(async () => {
owner = await createOwner();
admin = await createAdmin();
member = await createMember();
ownerAgent = testServer.authAgentFor(owner);
adminAgent = testServer.authAgentFor(admin);
memberAgent = testServer.authAgentFor(member);
store = Container.get(TrustedSourceDbStore);
cacheService = Container.get(CacheService);
rows = Container.get(DataSource).getRepository(TrustedSourceEntity);
bindings = Container.get(DataSource).getRepository(TrustedSourceIdentityEntity);
});
beforeEach(async () => {
// Module init seeded the system row once; only admin rows are per-test state.
await testDb.truncate(['TrustedSourceIdentityEntity']);
await rows.delete({ managedBy: 'admin' });
await cacheService.reset();
// The shared vitest config restores spies after every test, so the spy is per test.
emit = vi.spyOn(Container.get(EventService), 'emit');
discoveryClient.fetchOpenIdConfiguration.mockReset();
discoveryClient.fetchOAuth2ServerMetadata.mockReset();
discoveryClient.fetchJwks.mockReset();
});
describe('POST /trusted-sources', () => {
it('creates an admin source with defaults applied and returns it without the secret', async () => {
const response = await ownerAgent
.post('/trusted-sources')
.send(body({ config: withClient('s3cret') }));
expect(response.statusCode).toBe(200);
expect(response.body.data).toMatchObject({
name: 'Acme',
issuer: ISSUER,
type: 'oauth2',
managedBy: 'admin',
status: 'unchecked',
lastError: null,
lastCheckedAt: null,
config: {
version: 1,
authentication: {
type: 'oauth2',
clockSkewSeconds: 60,
client: { kind: 'registered', clientId: 'n8n', clientSecret: CREDENTIAL_BLANKING_VALUE },
},
identity: { subject: 'binding' },
},
});
expect(JSON.stringify(response.body)).not.toContain('s3cret');
const row = await rows.findOneByOrFail({ id: response.body.data.id });
expect(row).toMatchObject({ managedBy: 'admin', status: 'unchecked', configVersion: 1 });
expect(await storedClient(row.id)).toEqual({
kind: 'registered',
clientId: 'n8n',
clientSecret: 's3cret',
});
});
it('lets an admin create a source', async () => {
const response = await adminAgent.post('/trusted-sources').send(body());
expect(response.statusCode).toBe(200);
expect(response.body.data.managedBy).toBe('admin');
});
it.each<[string, Record<string, unknown>, string[], RegExp]>([
[
'a system-only setting',
{ config: { ...minimalConfig(), identity: { subject: 'n8n-user-id' } } },
['config', 'identity', 'subject'],
/only allowed on system-managed sources/,
],
[
'a value outside the schema bounds',
{ config: { ...minimalConfig(), authentication: { type: 'oauth2', clockSkewSeconds: 999 } } },
['config', 'authentication', 'clockSkewSeconds'],
/300/,
],
['an issuer that is not a URL', { issuer: 'acme' }, ['issuer'], /url/i],
['an http issuer', { issuer: 'http://acme.example.com' }, ['issuer'], /https/],
[
'an unknown config key',
{ config: { ...minimalConfig(), identity: { typo: true } } },
['config', 'identity'],
/typo/,
],
])(
'refuses %s with the offending path and writes nothing',
async (_, overrides, path, message) => {
const response = await ownerAgent.post('/trusted-sources').send(body(overrides));
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({ path, message: expect.stringMatching(message) });
expect(await rows.countBy({ managedBy: 'admin' })).toBe(0);
},
);
it('refuses the blanking value as a secret and writes nothing', async () => {
const response = await ownerAgent
.post('/trusted-sources')
.send(body({ config: withClient(CREDENTIAL_BLANKING_VALUE) }));
expect(response.statusCode).toBe(400);
expect(response.body.message).toMatch(/blanking value/);
expect(await rows.countBy({ managedBy: 'admin' })).toBe(0);
});
it('answers 409 when the name or the issuer is taken', async () => {
await createSource();
const sameName = await ownerAgent
.post('/trusted-sources')
.send(body({ issuer: 'https://other.example.com' }));
const sameIssuer = await ownerAgent.post('/trusted-sources').send(body({ name: 'Other' }));
expect(sameName.statusCode).toBe(409);
expect(sameIssuer.statusCode).toBe(409);
expect(await rows.countBy({ managedBy: 'admin' })).toBe(1);
});
it('emits trusted-source-created with the actor and the source', async () => {
const { id } = await createSource();
expect(emit).toHaveBeenCalledWith('trusted-source-created', {
user: expect.objectContaining({ id: owner.id }),
sourceId: id,
issuer: ISSUER,
});
});
it('answers 403 for a member', async () => {
const response = await memberAgent.post('/trusted-sources').send(body());
expect(response.statusCode).toBe(403);
expect(await rows.countBy({ managedBy: 'admin' })).toBe(0);
});
it('answers 403 without the license', async () => {
testServer.license.disable(LICENSE);
const response = await ownerAgent.post('/trusted-sources').send(body());
expect(response.statusCode).toBe(403);
expect(response.body.message).toBe('Plan lacks license for this feature');
expect(await rows.countBy({ managedBy: 'admin' })).toBe(0);
});
});
describe('GET /trusted-sources', () => {
it('lists the seeded source and admin sources as summaries for an owner', async () => {
const { id } = await createSource({
config: {
...minimalConfig(),
authentication: { type: 'oauth2', discovery: { mode: 'manual', jwksUri: JWKS_URI } },
surfaces: { 'public-api': {}, trigger: {} },
},
});
const response = await ownerAgent.get('/trusted-sources');
expect(response.statusCode).toBe(200);
const list: Record<string, unknown>[] = response.body.data;
expect(list.map((entry) => entry.id).sort()).toEqual([id, SYSTEM_ID].sort());
for (const entry of list) {
expect(Object.keys(entry).sort()).toEqual(['capabilities', 'id', 'name', 'surfaces']);
}
expect(list.find((entry) => entry.id === id)).toEqual({
id,
name: 'Acme',
capabilities: ['verify-jwt'],
surfaces: ['public-api', 'trigger'],
});
});
it('lists the same summaries for a member', async () => {
const { id } = await createSource();
const response = await memberAgent.get('/trusted-sources');
expect(response.statusCode).toBe(200);
expect(response.body.data).toContainEqual({
id,
name: 'Acme',
capabilities: [],
surfaces: ['public-api'],
});
expect(JSON.stringify(response.body)).not.toContain(ISSUER);
});
it('lists without the license', async () => {
testServer.license.disable(LICENSE);
const response = await ownerAgent.get('/trusted-sources');
expect(response.statusCode).toBe(200);
});
});
describe('GET /trusted-sources/:id', () => {
it('returns the full source with the secret blanked for an owner', async () => {
const { id } = await createSource({ config: withClient('s3cret') });
const response = await ownerAgent.get(`/trusted-sources/${id}`);
expect(response.statusCode).toBe(200);
expect(response.body.data).toMatchObject({
id,
name: 'Acme',
issuer: ISSUER,
managedBy: 'admin',
status: 'unchecked',
config: {
authentication: {
client: { kind: 'registered', clientId: 'n8n', clientSecret: CREDENTIAL_BLANKING_VALUE },
},
},
metadata: null,
});
expect(JSON.stringify(response.body)).not.toContain('s3cret');
});
it('returns the seeded system source', async () => {
const response = await ownerAgent.get(`/trusted-sources/${SYSTEM_ID}`);
expect(response.statusCode).toBe(200);
expect(response.body.data).toMatchObject({ id: SYSTEM_ID, managedBy: 'system' });
});
it('answers 403 for a member', async () => {
const { id } = await createSource();
const response = await memberAgent.get(`/trusted-sources/${id}`);
expect(response.statusCode).toBe(403);
});
it('answers 404 for an unknown id', async () => {
const response = await ownerAgent.get('/trusted-sources/missing');
expect(response.statusCode).toBe(404);
});
});
describe('PATCH /trusted-sources/:id', () => {
it('renames a source, keeps its bindings and emits trusted-source-updated', async () => {
const { id } = await createSource();
await bind(id, 'alice');
const response = await ownerAgent.patch(`/trusted-sources/${id}`).send({ name: 'Renamed' });
expect(response.statusCode).toBe(200);
expect(response.body.data).toMatchObject({ id, name: 'Renamed', issuer: ISSUER });
expect((await rows.findOneByOrFail({ id })).name).toBe('Renamed');
expect(await bindings.countBy({ sourceId: id })).toBe(1);
expect(emit).toHaveBeenCalledWith('trusted-source-updated', {
user: expect.objectContaining({ id: owner.id }),
sourceId: id,
issuer: ISSUER,
});
});
it('answers an empty body with the current source, writes nothing and emits nothing', async () => {
const { id } = await createSource();
const update = vi.spyOn(store, 'update');
const response = await ownerAgent.patch(`/trusted-sources/${id}`).send({});
expect(response.statusCode).toBe(200);
expect(response.body.data).toMatchObject({ id, name: 'Acme', issuer: ISSUER });
expect(update).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalledWith('trusted-source-updated', expect.anything());
});
it('keeps the stored secret when the config carries the blanking value', async () => {
const { id } = await createSource({ config: withClient('s3cret') });
const response = await ownerAgent
.patch(`/trusted-sources/${id}`)
.send({ config: withClient(CREDENTIAL_BLANKING_VALUE) });
expect(response.statusCode).toBe(200);
expect(response.body.data.config.authentication.client.clientSecret).toBe(
CREDENTIAL_BLANKING_VALUE,
);
expect(await storedClient(id)).toMatchObject({ clientSecret: 's3cret' });
});
it('replaces the secret when the config carries a new value', async () => {
const { id } = await createSource({ config: withClient('s3cret') });
const response = await ownerAgent
.patch(`/trusted-sources/${id}`)
.send({ config: withClient('n3w') });
expect(response.statusCode).toBe(200);
expect(await storedClient(id)).toMatchObject({ clientSecret: 'n3w' });
});
it('clears the secret when the config omits it', async () => {
const { id } = await createSource({ config: withClient('s3cret') });
const response = await ownerAgent
.patch(`/trusted-sources/${id}`)
.send({ config: withClient() });
expect(response.statusCode).toBe(200);
expect(await storedClient(id)).toEqual({ kind: 'registered', clientId: 'n8n' });
});
it('moves the issuer and drops bindings only when clearBindings is set', async () => {
const { id } = await createSource();
await bind(id, 'alice');
await bind(id, 'bob');
const kept = await ownerAgent
.patch(`/trusted-sources/${id}`)
.send({ issuer: 'https://moved.example.com' });
expect(kept.statusCode).toBe(200);
expect(await bindings.countBy({ sourceId: id })).toBe(2);
const cleared = await ownerAgent
.patch(`/trusted-sources/${id}`)
.send({ issuer: 'https://moved-again.example.com', clearBindings: true });
expect(cleared.statusCode).toBe(200);
expect(cleared.body.data).toMatchObject({
issuer: 'https://moved-again.example.com',
status: 'unchecked',
});
expect(await bindings.countBy({ sourceId: id })).toBe(0);
expect(await store.getByIssuer(ISSUER)).toBeUndefined();
expect(await store.getByIssuer('https://moved-again.example.com')).toMatchObject({ id });
});
it('refuses an admin-only setting without touching the row', async () => {
const { id } = await createSource();
const response = await ownerAgent.patch(`/trusted-sources/${id}`).send({
config: {
...minimalConfig(),
authentication: { type: 'oauth2', keys: { kind: 'local-keystore' } },
},
});
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
path: ['config', 'authentication', 'keys', 'kind'],
message: expect.stringMatching(/only allowed on system-managed sources/),
});
expect((await rows.findOneByOrFail({ id })).name).toBe('Acme');
});
it('answers 409 when the new issuer is taken', async () => {
const { id } = await createSource();
await createSource({ name: 'Other', issuer: 'https://other.example.com' });
const response = await ownerAgent
.patch(`/trusted-sources/${id}`)
.send({ issuer: 'https://other.example.com' });
expect(response.statusCode).toBe(409);
});
it('refuses the system source', async () => {
const response = await ownerAgent.patch(`/trusted-sources/${SYSTEM_ID}`).send({ name: 'x' });
expect(response.statusCode).toBe(400);
expect(response.body.message).toMatch(/system-managed/);
expect((await rows.findOneByOrFail({ id: SYSTEM_ID })).name).not.toBe('x');
expect(emit).not.toHaveBeenCalledWith('trusted-source-updated', expect.anything());
});
it('answers 404 for an unknown id', async () => {
const response = await ownerAgent.patch('/trusted-sources/missing').send({ name: 'x' });
expect(response.statusCode).toBe(404);
});
it('answers 403 for a member', async () => {
const { id } = await createSource();
const response = await memberAgent.patch(`/trusted-sources/${id}`).send({ name: 'x' });
expect(response.statusCode).toBe(403);
expect((await rows.findOneByOrFail({ id })).name).toBe('Acme');
});
it('answers 403 without the license', async () => {
const { id } = await createSource();
testServer.license.disable(LICENSE);
const response = await ownerAgent.patch(`/trusted-sources/${id}`).send({ name: 'x' });
expect(response.statusCode).toBe(403);
expect(response.body.message).toBe('Plan lacks license for this feature');
expect((await rows.findOneByOrFail({ id })).name).toBe('Acme');
});
});
describe('DELETE /trusted-sources/:id', () => {
it('deletes the source with its bindings, evicts the cache and emits trusted-source-deleted', async () => {
const { id } = await createSource();
await bind(id, 'alice');
// Prime both cache keys, so the test fails if delete stops evicting them.
expect(await store.getById(id)).toBeDefined();
expect(await store.getByIssuer(ISSUER)).toBeDefined();
const response = await ownerAgent.delete(`/trusted-sources/${id}`);
expect(response.statusCode).toBe(200);
expect(await rows.findOneBy({ id })).toBeNull();
expect(await bindings.countBy({ sourceId: id })).toBe(0);
expect(await store.getById(id)).toBeUndefined();
expect(await store.getByIssuer(ISSUER)).toBeUndefined();
expect(emit).toHaveBeenCalledWith('trusted-source-deleted', {
user: expect.objectContaining({ id: owner.id }),
sourceId: id,
issuer: ISSUER,
});
});
it('works without the license', async () => {
const { id } = await createSource();
testServer.license.disable(LICENSE);
const response = await ownerAgent.delete(`/trusted-sources/${id}`);
expect(response.statusCode).toBe(200);
expect(await rows.findOneBy({ id })).toBeNull();
});
it('refuses the system source', async () => {
const response = await ownerAgent.delete(`/trusted-sources/${SYSTEM_ID}`);
expect(response.statusCode).toBe(400);
expect(response.body.message).toMatch(/system-managed/);
expect(await rows.findOneBy({ id: SYSTEM_ID })).not.toBeNull();
expect(emit).not.toHaveBeenCalledWith('trusted-source-deleted', expect.anything());
});
it('answers 404 for an unknown id', async () => {
const response = await ownerAgent.delete('/trusted-sources/missing');
expect(response.statusCode).toBe(404);
});
it('answers 403 for a member', async () => {
const { id } = await createSource();
const response = await memberAgent.delete(`/trusted-sources/${id}`);
expect(response.statusCode).toBe(403);
expect(await rows.findOneBy({ id })).not.toBeNull();
});
});
describe('POST /trusted-sources/:id/check', () => {
it('marks a reachable issuer healthy and returns the checked source', async () => {
discoveryClient.fetchOpenIdConfiguration.mockResolvedValue({
document: { issuer: ISSUER, jwks_uri: JWKS_URI },
ttlSeconds: 3600,
});
discoveryClient.fetchOAuth2ServerMetadata.mockResolvedValue(undefined);
discoveryClient.fetchJwks.mockResolvedValue({ keys: [RSA_KEY], skipped: [], ttlSeconds: 3600 });
const { id } = await createSource();
const response = await ownerAgent.post(`/trusted-sources/${id}/check`);
expect(response.statusCode).toBe(200);
expect(response.body.data).toMatchObject({
id,
status: 'healthy',
lastError: null,
lastCheckedAt: expect.any(String),
metadata: { version: 1 },
});
expect((await rows.findOneByOrFail({ id })).status).toBe('healthy');
// Discovered keys make the source usable, which the summary shows as a capability.
const list = await ownerAgent.get('/trusted-sources');
expect(list.body.data).toContainEqual(
expect.objectContaining({ id, capabilities: ['verify-jwt'] }),
);
});
it('records an error when the metadata names another issuer', async () => {
const mismatch = new Error(
`Metadata from "${ISSUER}/.well-known/openid-configuration" names issuer "https://other.example.com", expected "${ISSUER}"`,
);
discoveryClient.fetchOpenIdConfiguration.mockRejectedValue(mismatch);
discoveryClient.fetchOAuth2ServerMetadata.mockRejectedValue(mismatch);
const { id } = await createSource();
const response = await ownerAgent.post(`/trusted-sources/${id}/check`);
expect(response.statusCode).toBe(200);
expect(response.body.data).toMatchObject({
id,
status: 'error',
lastError: expect.stringContaining('names issuer "https://other.example.com"'),
lastCheckedAt: expect.any(String),
});
expect((await rows.findOneByOrFail({ id })).status).toBe('error');
});
it('works without the license', async () => {
discoveryClient.fetchOpenIdConfiguration.mockRejectedValue(new Error('down'));
discoveryClient.fetchOAuth2ServerMetadata.mockRejectedValue(new Error('down'));
const { id } = await createSource();
testServer.license.disable(LICENSE);
const response = await ownerAgent.post(`/trusted-sources/${id}/check`);
expect(response.statusCode).toBe(200);
expect(response.body.data.status).toBe('error');
});
it('answers 404 for an unknown id', async () => {
const response = await ownerAgent.post('/trusted-sources/missing/check');
expect(response.statusCode).toBe(404);
});
it('answers 403 for a member', async () => {
const { id } = await createSource();
const response = await memberAgent.post(`/trusted-sources/${id}/check`);
expect(response.statusCode).toBe(403);
expect((await rows.findOneByOrFail({ id })).status).toBe('unchecked');
});
});

View File

@ -60,6 +60,7 @@ type EndpointGroup =
| 'third-party-licenses'
| 'mcp'
| 'workflowDependencies'
| 'trusted-sources'
| 'encryption-keys'
| 'workflow-reviews'
| 'test-webhooks'

View File

@ -428,6 +428,10 @@ export const setupTestServer = ({
await import('@/modules/encryption-key-manager/encryption-key.controller.js');
break;
case 'trusted-sources':
await import('@/modules/inbound-auth-core/trusted-source.controller.js');
break;
case 'test-webhooks':
await import('@/webhooks/test-webhooks.controller.js');
break;

View File

@ -65,6 +65,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
roleMappingRule: {},
otel: {},
scim: {},
trustedSource: {},
});
function addGlobalRole(role: Role) {

View File

@ -1619,6 +1619,9 @@ importers:
'@asteasolutions/zod-to-openapi':
specifier: 'catalog:'
version: 7.3.4(zod@3.25.76)
'@n8n/inbound-auth':
specifier: workspace:*
version: link:../inbound-auth
'@n8n/permissions':
specifier: workspace:*
version: link:../permissions
@ -3115,9 +3118,6 @@ importers:
packages/@n8n/inbound-auth:
dependencies:
'@n8n/api-types':
specifier: workspace:*
version: link:../api-types
'@n8n/permissions':
specifier: workspace:*
version: link:../permissions