From c40aaa557559acc483a6b3fac92152bbe1f33fc8 Mon Sep 17 00:00:00 2001 From: Stephen Wright Date: Fri, 17 Oct 2025 09:04:06 +0100 Subject: [PATCH] feat: Add GET /sso/provisioning/config endpoint for sso provisioning config (#20850) --- packages/@n8n/api-types/src/dto/index.ts | 2 + .../src/dto/provisioning/config.dto.ts | 11 ++ .../@n8n/backend-common/src/license-state.ts | 21 ++- .../modules/__tests__/module-registry.test.ts | 2 + .../src/modules/module-registry.ts | 3 +- .../src/modules/modules.config.ts | 1 + .../decorators/src/module/module-metadata.ts | 6 +- packages/@n8n/decorators/src/module/module.ts | 2 +- .../scope-information.test.ts.snap | 2 + packages/@n8n/permissions/src/constants.ee.ts | 1 + .../src/roles/scopes/global-scopes.ee.ts | 1 + .../get-resource-permissions.test.ts | 2 + .../__tests__/constants.test.ts | 7 + .../provisioning.controller.ee.test.ts | 52 +++++++ .../__tests__/provisioning.service.ee.test.ts | 135 ++++++++++++++++++ .../src/modules/provisioning.ee/constants.ts | 1 + .../provisioning.controller.ee.ts | 23 +++ .../provisioning.ee/provisioning.module.ts | 9 ++ .../provisioning.service.ee.ts | 65 +++++++++ .../editor-ui/src/stores/rbac.store.ts | 1 + 20 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/provisioning/config.dto.ts create mode 100644 packages/cli/src/modules/provisioning.ee/__tests__/constants.test.ts create mode 100644 packages/cli/src/modules/provisioning.ee/__tests__/provisioning.controller.ee.test.ts create mode 100644 packages/cli/src/modules/provisioning.ee/__tests__/provisioning.service.ee.test.ts create mode 100644 packages/cli/src/modules/provisioning.ee/constants.ts create mode 100644 packages/cli/src/modules/provisioning.ee/provisioning.controller.ee.ts create mode 100644 packages/cli/src/modules/provisioning.ee/provisioning.module.ts create mode 100644 packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 5f4a92d1f05..0135b1d791a 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -98,3 +98,5 @@ export { CreateDataTableColumnDto } from './data-table/create-data-table-column. export { AddDataTableRowsDto } from './data-table/add-data-table-rows.dto'; export { AddDataTableColumnDto } from './data-table/add-data-table-column.dto'; export { MoveDataTableColumnDto } from './data-table/move-data-table-column.dto'; + +export { ProvisioningConfigDto } from './provisioning/config.dto'; diff --git a/packages/@n8n/api-types/src/dto/provisioning/config.dto.ts b/packages/@n8n/api-types/src/dto/provisioning/config.dto.ts new file mode 100644 index 00000000000..fe67ce5204d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/provisioning/config.dto.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class ProvisioningConfigDto extends Z.class({ + scopesProvisionInstanceRole: z.boolean(), + scopesProvisionProjectRoles: z.boolean(), + scopesProvisioningFrequency: z.enum(['never', 'first_login', 'every_login']), + scopesName: z.string(), + scopesInstanceRoleClaimName: z.string(), + scopesProjectsRolesClaimName: z.string(), +}) {} diff --git a/packages/@n8n/backend-common/src/license-state.ts b/packages/@n8n/backend-common/src/license-state.ts index ca226c064c3..dc3afe261e6 100644 --- a/packages/@n8n/backend-common/src/license-state.ts +++ b/packages/@n8n/backend-common/src/license-state.ts @@ -26,11 +26,22 @@ export class LicenseState { // -------------------- // core queries // -------------------- - - isLicensed(feature: BooleanLicenseFeature) { + /* + * If the feature is a string. checks if the feature is licensed + * If the feature is an array of strings, it checks if any of the features are licensed + */ + isLicensed(feature: BooleanLicenseFeature | BooleanLicenseFeature[]) { this.assertProvider(); - return this.licenseProvider.isLicensed(feature); + if (typeof feature === 'string') return this.licenseProvider.isLicensed(feature); + + for (const featureName of feature) { + if (this.licenseProvider.isLicensed(featureName)) { + return true; + } + } + + return false; } getValue(feature: T): FeatureReturnType[T] { @@ -167,6 +178,10 @@ export class LicenseState { return this.isLicensed('feat:workflowDiffs'); } + isProvisioningLicensed() { + return this.isLicensed(['feat:saml', 'feat:oidc', 'feat:ldap']); + } + // -------------------- // integers // -------------------- diff --git a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts index 40f6cae38d7..b2f8fe23519 100644 --- a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts +++ b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts @@ -27,6 +27,7 @@ describe('eligibleModules', () => { 'external-secrets', 'community-packages', 'data-table', + 'provisioning', ]); }); @@ -37,6 +38,7 @@ describe('eligibleModules', () => { 'external-secrets', 'community-packages', 'data-table', + 'provisioning', ]); }); diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index 3d5b6c571e2..f358a14fc76 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -33,6 +33,7 @@ export class ModuleRegistry { 'external-secrets', 'community-packages', 'data-table', + 'provisioning', ]; private readonly activeModules: string[] = []; @@ -108,7 +109,7 @@ export class ModuleRegistry { for (const [moduleName, moduleEntry] of this.moduleMetadata.getEntries()) { const { licenseFlag, class: ModuleClass } = moduleEntry; - if (licenseFlag && !this.licenseState.isLicensed(licenseFlag)) { + if (licenseFlag !== undefined && !this.licenseState.isLicensed(licenseFlag)) { this.logger.debug(`Skipped init for unlicensed module "${moduleName}"`); continue; } diff --git a/packages/@n8n/backend-common/src/modules/modules.config.ts b/packages/@n8n/backend-common/src/modules/modules.config.ts index 2816afef704..27511445e6b 100644 --- a/packages/@n8n/backend-common/src/modules/modules.config.ts +++ b/packages/@n8n/backend-common/src/modules/modules.config.ts @@ -9,6 +9,7 @@ export const MODULE_NAMES = [ 'data-table', 'mcp', 'chat-hub', + 'provisioning', ] as const; export type ModuleName = (typeof MODULE_NAMES)[number]; diff --git a/packages/@n8n/decorators/src/module/module-metadata.ts b/packages/@n8n/decorators/src/module/module-metadata.ts index 72b54e17372..2404c910d67 100644 --- a/packages/@n8n/decorators/src/module/module-metadata.ts +++ b/packages/@n8n/decorators/src/module/module-metadata.ts @@ -4,7 +4,11 @@ import type { LicenseFlag, ModuleClass } from './module'; type ModuleEntry = { class: ModuleClass; - licenseFlag?: LicenseFlag; + /* + * If singular, checks if that feature ls licensed, + * if multiple, checks that any of the features are licensed + */ + licenseFlag?: LicenseFlag | LicenseFlag[]; }; @Service() diff --git a/packages/@n8n/decorators/src/module/module.ts b/packages/@n8n/decorators/src/module/module.ts index 413395431e7..61413d3098c 100644 --- a/packages/@n8n/decorators/src/module/module.ts +++ b/packages/@n8n/decorators/src/module/module.ts @@ -84,7 +84,7 @@ export type ModuleClass = Constructable; export type LicenseFlag = (typeof LICENSE_FEATURES)[keyof typeof LICENSE_FEATURES]; export const BackendModule = - (opts: { name: string; licenseFlag?: LicenseFlag }): ClassDecorator => + (opts: { name: string; licenseFlag?: LicenseFlag | LicenseFlag[] }): ClassDecorator => (target) => { Container.get(ModuleMetadata).register(opts.name, { class: target as unknown as ModuleClass, 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 52a4fe32dcf..1a5522301c8 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 @@ -120,6 +120,8 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = ` "insights:*", "oidc:manage", "oidc:*", + "provisioning:manage", + "provisioning:*", "dataTable:create", "dataTable:read", "dataTable:update", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 800694925a2..34bed98d343 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -27,6 +27,7 @@ export const RESOURCES = { folder: [...DEFAULT_OPERATIONS, 'move'] as const, insights: ['list'] as const, oidc: ['manage'] as const, + provisioning: ['manage'] as const, dataTable: [...DEFAULT_OPERATIONS, 'readRow', 'writeRow', 'listProject'] as const, execution: ['delete', 'read', 'retry', 'list', 'get'] as const, workflowTags: ['update', 'list'] as const, 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 6561cc1386c..a35e1c46ae2 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -89,6 +89,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'folder:create', 'folder:list', 'oidc:manage', + 'provisioning:manage', 'dataTable:list', 'role:manage', 'mcp:manage', 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 06060b6d73c..3ac17da4ba5 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 @@ -23,6 +23,7 @@ describe('permissions', () => { orchestration: {}, project: {}, saml: {}, + provisioning: {}, securityAudit: {}, sourceControl: {}, tag: {}, @@ -106,6 +107,7 @@ describe('permissions', () => { }, saml: {}, oidc: {}, + provisioning: {}, mcp: {}, mcpApiKey: {}, securityAudit: {}, diff --git a/packages/cli/src/modules/provisioning.ee/__tests__/constants.test.ts b/packages/cli/src/modules/provisioning.ee/__tests__/constants.test.ts new file mode 100644 index 00000000000..f783e1a53e7 --- /dev/null +++ b/packages/cli/src/modules/provisioning.ee/__tests__/constants.test.ts @@ -0,0 +1,7 @@ +import { PROVISIONING_PREFERENCES_DB_KEY } from '../constants'; + +describe('constants', () => { + it('should have the correct constants', () => { + expect(PROVISIONING_PREFERENCES_DB_KEY).toBe('features.provisioning'); + }); +}); diff --git a/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.controller.ee.test.ts b/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.controller.ee.test.ts new file mode 100644 index 00000000000..efc62402b91 --- /dev/null +++ b/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.controller.ee.test.ts @@ -0,0 +1,52 @@ +import type { LicenseState } from '@n8n/backend-common'; +import { mock } from 'jest-mock-extended'; + +import { ProvisioningController } from '../provisioning.controller.ee'; +import { type ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; +import { type Response } from 'express'; +import { type AuthenticatedRequest } from '@n8n/db'; +import { type ProvisioningConfigDto } from '@n8n/api-types'; + +const provisioningService = mock(); +const licenseState = mock(); + +const controller = new ProvisioningController(provisioningService, licenseState); + +describe('ProvisioningController', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getConfig', () => { + const req = mock(); + const res = mock({ + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + }); + + it('should return 403 if provisioning is not licensed', async () => { + licenseState.isProvisioningLicensed.mockReturnValue(false); + await controller.getConfig(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('should return the provisioning config', async () => { + const configResponse: ProvisioningConfigDto = { + scopesProvisionInstanceRole: true, + scopesProvisionProjectRoles: true, + scopesProvisioningFrequency: 'every_login', + scopesName: 'n8n_test_scope', + scopesInstanceRoleClaimName: 'n8n_test_instance_role', + scopesProjectsRolesClaimName: 'n8n_test_projects_roles', + }; + + licenseState.isProvisioningLicensed.mockReturnValue(true); + provisioningService.getConfig.mockResolvedValue(configResponse); + + const config = await controller.getConfig(req, res); + + expect(config).toEqual(configResponse); + }); + }); +}); diff --git a/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.service.ee.test.ts b/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.service.ee.test.ts new file mode 100644 index 00000000000..74980f6bcb1 --- /dev/null +++ b/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.service.ee.test.ts @@ -0,0 +1,135 @@ +import type { Logger } from '@n8n/backend-common'; +import { mock } from 'jest-mock-extended'; + +import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; +import { type SettingsRepository } from '@n8n/db'; +import { type GlobalConfig } from '@n8n/config'; +import { PROVISIONING_PREFERENCES_DB_KEY } from '../constants'; +import { type ProvisioningConfigDto } from '@n8n/api-types'; + +const globalConfig = mock(); +const settingsRepository = mock(); +const logger = mock(); + +const provisioningService = new ProvisioningService(globalConfig, settingsRepository, logger); + +describe('ProvisioningService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const provisioningConfigDto: ProvisioningConfigDto = { + scopesProvisionInstanceRole: true, + scopesProvisionProjectRoles: true, + scopesProvisioningFrequency: 'every_login', + scopesName: 'n8n_test_scope', + scopesInstanceRoleClaimName: 'n8n_test_instance_role', + scopesProjectsRolesClaimName: 'n8n_test_projects_roles', + }; + + describe('init', () => { + it('should set provisioning config from the result of loadConfig', async () => { + const originStateLoadConfig = provisioningService.loadConfig; + + provisioningService.loadConfig = jest.fn().mockResolvedValue({ foo: 'bar' }); + + await provisioningService.init(); + // @ts-expect-error - provisioningConfig is private and only accessible within the class + expect(provisioningService.provisioningConfig).toEqual({ foo: 'bar' }); + + provisioningService.loadConfig = originStateLoadConfig; + }); + }); + + describe('getConfig', () => { + it('should set provisioning config from the result of loadConfig, then return it if it is not set', async () => { + const originStateLoadConfig = provisioningService.loadConfig; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + provisioningService.provisioningConfig = undefined; + + provisioningService.loadConfig = jest.fn().mockResolvedValue({ foo: 'bar' }); + + const config = await provisioningService.getConfig(); + expect(config).toEqual({ foo: 'bar' }); + expect(provisioningService.loadConfig).toHaveBeenCalledTimes(1); + + provisioningService.loadConfig = originStateLoadConfig; + }); + + it('should return the provisioning config', async () => { + // @ts-expect-error - provisioningConfig is private and only accessible within the class + provisioningService.provisioningConfig = { foo: 'bar' }; + + const config = await provisioningService.getConfig(); + expect(config).toEqual({ foo: 'bar' }); + }); + }); + + describe('loadConfigurationFromDatabase', () => { + it('should return the provisioning config from the database', async () => { + settingsRepository.findByKey.mockResolvedValue({ + key: PROVISIONING_PREFERENCES_DB_KEY, + value: JSON.stringify(provisioningConfigDto), + loadOnStartup: true, + }); + + const config = await provisioningService.loadConfigurationFromDatabase(); + expect(config).toEqual(provisioningConfigDto); + }); + + it('should return undefined if the provisioning config is not found in the database', async () => { + settingsRepository.findByKey.mockResolvedValue(null); + + const config = await provisioningService.loadConfigurationFromDatabase(); + expect(config).toBeUndefined(); + }); + + it('should return undefined if the provisioning config is invalid', async () => { + settingsRepository.findByKey.mockResolvedValue({ + key: PROVISIONING_PREFERENCES_DB_KEY, + value: 'invalid', + loadOnStartup: true, + }); + + const config = await provisioningService.loadConfigurationFromDatabase(); + expect(config).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to load Provisioning configuration from database, falling back to default configuration.', + { error: expect.any(Error) }, + ); + }); + }); + + describe('loadConfig', () => { + it('should return the provisioning config from the database, overriding the environment configuration', async () => { + globalConfig.sso.provisioning = provisioningConfigDto; + + const overriddenConfig = { + scopesProvisionInstanceRole: false, + scopesProvisionProjectRoles: false, + scopesProvisioningFrequency: 'never', + scopesName: 'n8n_test_scope_overridden', + scopesInstanceRoleClaimName: 'n8n_test_instance_role_overridden', + scopesProjectsRolesClaimName: 'n8n_test_projects_roles_overridden', + }; + settingsRepository.findByKey.mockResolvedValue({ + key: PROVISIONING_PREFERENCES_DB_KEY, + value: JSON.stringify(overriddenConfig), + loadOnStartup: true, + }); + + const config = await provisioningService.loadConfig(); + expect(config).toEqual(overriddenConfig); + }); + + it('should return the environment configuration if the database configuration is not found', async () => { + globalConfig.sso.provisioning = provisioningConfigDto; + + settingsRepository.findByKey.mockResolvedValue(null); + + const config = await provisioningService.loadConfig(); + expect(config).toEqual(provisioningConfigDto); + }); + }); +}); diff --git a/packages/cli/src/modules/provisioning.ee/constants.ts b/packages/cli/src/modules/provisioning.ee/constants.ts new file mode 100644 index 00000000000..9a2fbf2c068 --- /dev/null +++ b/packages/cli/src/modules/provisioning.ee/constants.ts @@ -0,0 +1 @@ +export const PROVISIONING_PREFERENCES_DB_KEY = 'features.provisioning'; diff --git a/packages/cli/src/modules/provisioning.ee/provisioning.controller.ee.ts b/packages/cli/src/modules/provisioning.ee/provisioning.controller.ee.ts new file mode 100644 index 00000000000..be8a702e2a0 --- /dev/null +++ b/packages/cli/src/modules/provisioning.ee/provisioning.controller.ee.ts @@ -0,0 +1,23 @@ +import { AuthenticatedRequest } from '@n8n/db'; +import { Get, GlobalScope, RestController } from '@n8n/decorators'; +import { LicenseState } from '@n8n/backend-common'; +import { ProvisioningService } from './provisioning.service.ee'; +import { Response } from 'express'; + +@RestController('/sso/provisioning') +export class ProvisioningController { + constructor( + private readonly provisioningService: ProvisioningService, + private readonly licenseState: LicenseState, + ) {} + + @Get('/config') + @GlobalScope('provisioning:manage') + async getConfig(_req: AuthenticatedRequest, res: Response) { + if (!this.licenseState.isProvisioningLicensed()) { + return res.status(403).json({ message: 'Provisioning is not licensed' }); + } + + return await this.provisioningService.getConfig(); + } +} diff --git a/packages/cli/src/modules/provisioning.ee/provisioning.module.ts b/packages/cli/src/modules/provisioning.ee/provisioning.module.ts new file mode 100644 index 00000000000..c0ff2f1838a --- /dev/null +++ b/packages/cli/src/modules/provisioning.ee/provisioning.module.ts @@ -0,0 +1,9 @@ +import type { ModuleInterface } from '@n8n/decorators'; +import { BackendModule } from '@n8n/decorators'; + +@BackendModule({ name: 'provisioning', licenseFlag: ['feat:oidc', 'feat:saml', 'feat:ldap'] }) +export class ProvisioningModule implements ModuleInterface { + async init() { + await import('./provisioning.controller.ee'); + } +} diff --git a/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts b/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts new file mode 100644 index 00000000000..2dc86f137b0 --- /dev/null +++ b/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts @@ -0,0 +1,65 @@ +import { ProvisioningConfigDto } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { GlobalConfig } from '@n8n/config'; +import { SettingsRepository } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { jsonParse } from 'n8n-workflow'; + +import { PROVISIONING_PREFERENCES_DB_KEY } from './constants'; + +@Service() +export class ProvisioningService { + private provisioningConfig: ProvisioningConfigDto; + + constructor( + private readonly globalConfig: GlobalConfig, + private readonly settingsRepository: SettingsRepository, + private readonly logger: Logger, + ) {} + + async init() { + this.provisioningConfig = await this.loadConfig(); + } + + async getConfig(): Promise { + if (!this.provisioningConfig) { + this.provisioningConfig = await this.loadConfig(); + } + + return this.provisioningConfig; + } + + async loadConfigurationFromDatabase(): Promise { + const configFromDB = await this.settingsRepository.findByKey(PROVISIONING_PREFERENCES_DB_KEY); + + if (configFromDB) { + try { + const configValue = jsonParse(configFromDB.value); + + return ProvisioningConfigDto.parse(configValue); + } catch (error) { + this.logger.warn( + 'Failed to load Provisioning configuration from database, falling back to default configuration.', + + { error }, + ); + } + } + return undefined; + } + + async loadConfig(): Promise { + const envProvidedConfig = ProvisioningConfigDto.parse(this.globalConfig.sso.provisioning); + + const dbProvidedConfig = await this.loadConfigurationFromDatabase(); + + if (dbProvidedConfig) { + return { + ...envProvidedConfig, + ...dbProvidedConfig, + }; + } + + return envProvidedConfig; + } +} diff --git a/packages/frontend/editor-ui/src/stores/rbac.store.ts b/packages/frontend/editor-ui/src/stores/rbac.store.ts index 7825236b67b..5155f6409c5 100644 --- a/packages/frontend/editor-ui/src/stores/rbac.store.ts +++ b/packages/frontend/editor-ui/src/stores/rbac.store.ts @@ -35,6 +35,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { logStreaming: {}, saml: {}, oidc: {}, + provisioning: {}, securityAudit: {}, folder: {}, insights: {},