mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
2019ea5593
commit
5da4d19e50
|
|
@ -42,7 +42,8 @@
|
|||
"n8n-workflow": "workspace:*",
|
||||
"validator": "catalog:",
|
||||
"xss": "catalog:",
|
||||
"@n8n/permissions": "workspace:*"
|
||||
"@n8n/permissions": "workspace:*",
|
||||
"@n8n/inbound-auth": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "catalog:"
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}) {}
|
||||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@
|
|||
"dist/**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@n8n/api-types": "workspace:*",
|
||||
"@n8n/permissions": "workspace:*",
|
||||
"n8n-workflow": "workspace:*"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -60,6 +60,7 @@ type EndpointGroup =
|
|||
| 'third-party-licenses'
|
||||
| 'mcp'
|
||||
| 'workflowDependencies'
|
||||
| 'trusted-sources'
|
||||
| 'encryption-keys'
|
||||
| 'workflow-reviews'
|
||||
| 'test-webhooks'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
|
|||
roleMappingRule: {},
|
||||
otel: {},
|
||||
scim: {},
|
||||
trustedSource: {},
|
||||
});
|
||||
|
||||
function addGlobalRole(role: Role) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user