diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 04037d35450..79ace0c9e6b 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -42,7 +42,8 @@ "n8n-workflow": "workspace:*", "validator": "catalog:", "xss": "catalog:", - "@n8n/permissions": "workspace:*" + "@n8n/permissions": "workspace:*", + "@n8n/inbound-auth": "workspace:*" }, "peerDependencies": { "zod": "catalog:" diff --git a/packages/@n8n/api-types/src/consent.ts b/packages/@n8n/api-types/src/consent.ts index fa20ecd4374..906d378183d 100644 --- a/packages/@n8n/api-types/src/consent.ts +++ b/packages/@n8n/api-types/src/consent.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 1ecaafcc4b4..6ef7a82275f 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/dto/trusted-source/__tests__/create-trusted-source.dto.test.ts b/packages/@n8n/api-types/src/dto/trusted-source/__tests__/create-trusted-source.dto.test.ts new file mode 100644 index 00000000000..0d8d385ce6e --- /dev/null +++ b/packages/@n8n/api-types/src/dto/trusted-source/__tests__/create-trusted-source.dto.test.ts @@ -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 = {}) => ({ + 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, + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/trusted-source/create-trusted-source.dto.ts b/packages/@n8n/api-types/src/dto/trusted-source/create-trusted-source.dto.ts new file mode 100644 index 00000000000..8a39eaa5842 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/trusted-source/create-trusted-source.dto.ts @@ -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 { + 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 = [], +): { path: Array; 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, +}) {} diff --git a/packages/@n8n/api-types/src/dto/trusted-source/update-trusted-source.dto.ts b/packages/@n8n/api-types/src/dto/trusted-source/update-trusted-source.dto.ts new file mode 100644 index 00000000000..db0b95d5778 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/trusted-source/update-trusted-source.dto.ts @@ -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(), +}) {} diff --git a/packages/@n8n/api-types/tsconfig.json b/packages/@n8n/api-types/tsconfig.json index f693c746bb6..57b21005add 100644 --- a/packages/@n8n/api-types/tsconfig.json +++ b/packages/@n8n/api-types/tsconfig.json @@ -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" } ] } diff --git a/packages/@n8n/constants/src/index.ts b/packages/@n8n/constants/src/index.ts index 41e06f83f80..df54a117294 100644 --- a/packages/@n8n/constants/src/index.ts +++ b/packages/@n8n/constants/src/index.ts @@ -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 = { diff --git a/packages/@n8n/inbound-auth/package.json b/packages/@n8n/inbound-auth/package.json index ec622c54d51..90a9677ba89 100644 --- a/packages/@n8n/inbound-auth/package.json +++ b/packages/@n8n/inbound-auth/package.json @@ -23,7 +23,6 @@ "dist/**/*" ], "dependencies": { - "@n8n/api-types": "workspace:*", "@n8n/permissions": "workspace:*", "n8n-workflow": "workspace:*" }, diff --git a/packages/@n8n/inbound-auth/src/contracts.ts b/packages/@n8n/inbound-auth/src/contracts.ts index 6cdb363e0d2..b6029f3e9df 100644 --- a/packages/@n8n/inbound-auth/src/contracts.ts +++ b/packages/@n8n/inbound-auth/src/contracts.ts @@ -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. * diff --git a/packages/@n8n/inbound-auth/src/trusted-source-config.ts b/packages/@n8n/inbound-auth/src/trusted-source-config.ts index d4a5be78515..ec9e5f5c913 100644 --- a/packages/@n8n/inbound-auth/src/trusted-source-config.ts +++ b/packages/@n8n/inbound-auth/src/trusted-source-config.ts @@ -38,7 +38,7 @@ export const ManagedBySchema = z.enum(['system', 'admin']); export type ManagedBy = z.infer; // 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' }); diff --git a/packages/@n8n/inbound-auth/tsconfig.json b/packages/@n8n/inbound-auth/tsconfig.json index 2b8e2f65cef..33ccd974365 100644 --- a/packages/@n8n/inbound-auth/tsconfig.json +++ b/packages/@n8n/inbound-auth/tsconfig.json @@ -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" } ] diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index d7b31ad2522..250208c05ef 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -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", ] `; diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 4c63420e566..74dcedc0b03 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -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 = { diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index 70fa88a20b2..0cbc861f2ff 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -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[] = [ diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts index 133c1a8b896..cf7e157828a 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts @@ -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); diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index bcc526aa126..513744d4f7d 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -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 = { diff --git a/packages/cli/src/eventbus/event-message-classes/index.ts b/packages/cli/src/eventbus/event-message-classes/index.ts index b4f410da1c6..5538b252956 100644 --- a/packages/cli/src/eventbus/event-message-classes/index.ts +++ b/packages/cli/src/eventbus/event-message-classes/index.ts @@ -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 diff --git a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index 9bdd82d95ae..6abdc5f41c2 100644 --- a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -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', + }, + }); + }); + }); }); diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index f7bb9b751a1..51244a49692 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -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 diff --git a/packages/cli/src/events/relays/log-streaming.event-relay.ts b/packages/cli/src/events/relays/log-streaming.event-relay.ts index fcf449d90a3..676c5d5a474 100644 --- a/packages/cli/src/events/relays/log-streaming.event-relay.ts +++ b/packages/cli/src/events/relays/log-streaming.event-relay.ts @@ -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({ diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts index 3941de64270..9dccf2ecc93 100644 --- a/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts @@ -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(); @@ -28,6 +37,8 @@ describe('InboundAuthCoreModule', () => { const identityService = mock(); 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({ 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({ 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({ 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(); + }); + }); }); diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/trusted-source.controller.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/trusted-source.controller.test.ts new file mode 100644 index 00000000000..bbf61e658c6 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/trusted-source.controller.test.ts @@ -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, + }, + ]); + }); +}); diff --git a/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts b/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts index 6ad8ea9d5a0..22c02022804 100644 --- a/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts +++ b/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts @@ -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'); diff --git a/packages/cli/src/modules/inbound-auth-core/trusted-source.controller.ts b/packages/cli/src/modules/inbound-auth-core/trusted-source.controller.ts new file mode 100644 index 00000000000..1b7167115c8 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/trusted-source.controller.ts @@ -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(write: () => Promise): Promise { + 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 { + 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 { + const source = await this.store.getById(id); + if (!source) throw new NotFoundError(`Trusted source "${id}" not found`); + return source; + } +} diff --git a/packages/cli/src/modules/inbound-auth-core/trusted-source.store.ts b/packages/cli/src/modules/inbound-auth-core/trusted-source.store.ts index f25ad87b900..be7c7e7b302 100644 --- a/packages/cli/src/modules/inbound-auth-core/trusted-source.store.ts +++ b/packages/cli/src/modules/inbound-auth-core/trusted-source.store.ts @@ -93,12 +93,17 @@ export class TrustedSourceDbStore extends TrustedSourceStore { }); } - async listBySurface(surface: SurfaceId): Promise { - const all = + async listAll(): Promise { + return ( (await this.cacheService.get(ALL_KEY, { ttl: CACHE_TTL, refreshFn: async () => await this.loadAll(), - })) ?? []; + })) ?? [] + ); + } + + async listBySurface(surface: SurfaceId): Promise { + const all = await this.listAll(); return all.filter((source) => source.config.surfaces[surface] !== undefined); } diff --git a/packages/cli/test/integration/inbound-auth-core/trusted-source.api.test.ts b/packages/cli/test/integration/inbound-auth-core/trusted-source.api.test.ts new file mode 100644 index 00000000000..8b0d5fb4de8 --- /dev/null +++ b/packages/cli/test/integration/inbound-auth-core/trusted-source.api.test.ts @@ -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(); +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; +let bindings: Repository; +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 = {}) => ({ + name: 'Acme', + issuer: ISSUER, + config: minimalConfig(), + ...overrides, +}); + +async function createSource(overrides: Record = {}): 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[], 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[] = 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'); + }); +}); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 4e267d5bc95..ec325c0fe60 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -60,6 +60,7 @@ type EndpointGroup = | 'third-party-licenses' | 'mcp' | 'workflowDependencies' + | 'trusted-sources' | 'encryption-keys' | 'workflow-reviews' | 'test-webhooks' diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index c3d7f1034ba..2f329470ea3 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -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; diff --git a/packages/frontend/@n8n/stores/src/rbac.store.ts b/packages/frontend/@n8n/stores/src/rbac.store.ts index 2c4eb293730..29978075c85 100644 --- a/packages/frontend/@n8n/stores/src/rbac.store.ts +++ b/packages/frontend/@n8n/stores/src/rbac.store.ts @@ -65,6 +65,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { roleMappingRule: {}, otel: {}, scim: {}, + trustedSource: {}, }); function addGlobalRole(role: Role) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc411e826c4..4e9f30f956c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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