From 3103d3879832e555ec8b0444e7408bf176efe6ce Mon Sep 17 00:00:00 2001 From: Thanasis G <96360514+gthanasis@users.noreply.github.com> Date: Fri, 22 May 2026 18:11:45 +0300 Subject: [PATCH] fix(core): Write full SSO provisioning config from env-managed loader (#30885) Co-authored-by: Claude Opus 4.7 (1M context) --- ...visioning.instance-settings-loader.test.ts | 43 ++++++++++++++++- .../provisioning.instance-settings-loader.ts | 46 +++++++++++-------- 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/instance-settings-loader/__tests__/sso/provisioning.instance-settings-loader.test.ts b/packages/cli/src/instance-settings-loader/__tests__/sso/provisioning.instance-settings-loader.test.ts index 143550d41de..c1f31c8701f 100644 --- a/packages/cli/src/instance-settings-loader/__tests__/sso/provisioning.instance-settings-loader.test.ts +++ b/packages/cli/src/instance-settings-loader/__tests__/sso/provisioning.instance-settings-loader.test.ts @@ -1,7 +1,9 @@ +import { ProvisioningConfigDto } from '@n8n/api-types'; import type { Logger } from '@n8n/backend-common'; -import type { InstanceSettingsLoaderConfig } from '@n8n/config'; +import type { GlobalConfig, InstanceSettingsLoaderConfig } from '@n8n/config'; import type { SettingsRepository } from '@n8n/db'; import { mock } from 'jest-mock-extended'; +import { jsonParse } from 'n8n-workflow'; import { ProvisioningInstanceSettingsLoader } from '../../loaders/sso/provisioning.instance-settings-loader'; @@ -9,12 +11,22 @@ describe('ProvisioningInstanceSettingsLoader', () => { const logger = mock({ scoped: jest.fn().mockReturnThis() }); const settingsRepository = mock(); + const globalConfig = { + sso: { + provisioning: { + scopesName: 'n8n', + scopesInstanceRoleClaimName: 'n8n_instance_role', + scopesProjectsRolesClaimName: 'n8n_projects', + }, + }, + } as GlobalConfig; + const createLoader = (configOverrides: Partial = {}) => { const config = { ssoUserRoleProvisioning: 'disabled', ...configOverrides, } as InstanceSettingsLoaderConfig; - return new ProvisioningInstanceSettingsLoader(config, settingsRepository, logger); + return new ProvisioningInstanceSettingsLoader(config, globalConfig, settingsRepository, logger); }; beforeEach(() => { @@ -40,6 +52,9 @@ describe('ProvisioningInstanceSettingsLoader', () => { scopesProvisionInstanceRole: false, scopesProvisionProjectRoles: false, scopesUseExpressionMapping: false, + scopesName: 'n8n', + scopesInstanceRoleClaimName: 'n8n_instance_role', + scopesProjectsRolesClaimName: 'n8n_projects', }), loadOnStartup: true, }, @@ -59,6 +74,9 @@ describe('ProvisioningInstanceSettingsLoader', () => { scopesProvisionInstanceRole: true, scopesProvisionProjectRoles: false, scopesUseExpressionMapping: false, + scopesName: 'n8n', + scopesInstanceRoleClaimName: 'n8n_instance_role', + scopesProjectsRolesClaimName: 'n8n_projects', }), loadOnStartup: true, }, @@ -78,10 +96,31 @@ describe('ProvisioningInstanceSettingsLoader', () => { scopesProvisionInstanceRole: true, scopesProvisionProjectRoles: true, scopesUseExpressionMapping: false, + scopesName: 'n8n', + scopesInstanceRoleClaimName: 'n8n_instance_role', + scopesProjectsRolesClaimName: 'n8n_projects', }), loadOnStartup: true, }, { conflictPaths: ['key'] }, ); }); + + describe('persisted value is consumable by ProvisioningConfigDto', () => { + // The loader writes the row that the provisioning service reads back via + // ProvisioningConfigDto.parse(). If parse fails the service silently falls + // back to disabled defaults and IdP role claims are ignored. + const modes = ['disabled', 'instance_role', 'instance_and_project_roles'] as const; + + it.each(modes)('round-trips for %s', async (mode) => { + const loader = createLoader({ ssoUserRoleProvisioning: mode }); + + await loader.apply(); + + const upsertCall = settingsRepository.upsert.mock.calls[0][0] as { value: string }; + const persisted = jsonParse(upsertCall.value); + + expect(() => ProvisioningConfigDto.parse(persisted)).not.toThrow(); + }); + }); }); diff --git a/packages/cli/src/instance-settings-loader/loaders/sso/provisioning.instance-settings-loader.ts b/packages/cli/src/instance-settings-loader/loaders/sso/provisioning.instance-settings-loader.ts index 3bf369c0051..723cbe4bc56 100644 --- a/packages/cli/src/instance-settings-loader/loaders/sso/provisioning.instance-settings-loader.ts +++ b/packages/cli/src/instance-settings-loader/loaders/sso/provisioning.instance-settings-loader.ts @@ -1,6 +1,6 @@ -import { type ProvisioningMode, type ProvisioningModeFlags } from '@n8n/api-types'; +import { ProvisioningConfigDto, type ProvisioningMode } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; -import { InstanceSettingsLoaderConfig } from '@n8n/config'; +import { GlobalConfig, InstanceSettingsLoaderConfig } from '@n8n/config'; import { SettingsRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { z } from 'zod'; @@ -15,28 +15,19 @@ const ENV_PROVISIONING_MODES = [ 'instance_and_project_roles', ] as const satisfies readonly ProvisioningMode[]; -const provisioningSchema = z - .object({ - ssoUserRoleProvisioning: z.enum(ENV_PROVISIONING_MODES, { - errorMap: () => ({ - message: `N8N_SSO_USER_ROLE_PROVISIONING must be one of: ${ENV_PROVISIONING_MODES.join(', ')}`, - }), +const modeSchema = z.object({ + ssoUserRoleProvisioning: z.enum(ENV_PROVISIONING_MODES, { + errorMap: () => ({ + message: `N8N_SSO_USER_ROLE_PROVISIONING must be one of: ${ENV_PROVISIONING_MODES.join(', ')}`, }), - }) - .transform( - (input): ProvisioningModeFlags => ({ - scopesProvisionInstanceRole: - input.ssoUserRoleProvisioning === 'instance_role' || - input.ssoUserRoleProvisioning === 'instance_and_project_roles', - scopesProvisionProjectRoles: input.ssoUserRoleProvisioning === 'instance_and_project_roles', - scopesUseExpressionMapping: false, - }), - ); + }), +}); @Service() export class ProvisioningInstanceSettingsLoader { constructor( private readonly config: InstanceSettingsLoaderConfig, + private readonly globalConfig: GlobalConfig, private readonly settingsRepository: SettingsRepository, private logger: Logger, ) { @@ -44,15 +35,30 @@ export class ProvisioningInstanceSettingsLoader { } async apply(): Promise { - const parsed = provisioningSchema.safeParse(this.config); + const parsed = modeSchema.safeParse(this.config); if (!parsed.success) { throw new InstanceBootstrappingError(parsed.error.issues[0].message); } + const mode = parsed.data.ssoUserRoleProvisioning; + const { provisioning } = this.globalConfig.sso; + + // Persist the full ProvisioningConfigDto shape. The read path rejects + // partial rows and silently falls back to disabled defaults. + const value: ProvisioningConfigDto = { + scopesProvisionInstanceRole: + mode === 'instance_role' || mode === 'instance_and_project_roles', + scopesProvisionProjectRoles: mode === 'instance_and_project_roles', + scopesUseExpressionMapping: false, + scopesName: provisioning.scopesName, + scopesInstanceRoleClaimName: provisioning.scopesInstanceRoleClaimName, + scopesProjectsRolesClaimName: provisioning.scopesProjectsRolesClaimName, + }; + await this.settingsRepository.upsert( { key: PROVISIONING_PREFERENCES_DB_KEY, - value: JSON.stringify(parsed.data), + value: JSON.stringify(value), loadOnStartup: true, }, { conflictPaths: ['key'] },