From fcbf1433f6b7715be52333b91fc2663e7a8193e7 Mon Sep 17 00:00:00 2001 From: Stephen Wright Date: Thu, 23 Oct 2025 10:12:49 +0100 Subject: [PATCH] feat: Allow OIDC logins to sync instance roles (#21055) Co-authored-by: konstantintieber --- .../__tests__/provisioning.service.ee.test.ts | 2 +- .../provisioning.service.ee.ts | 13 ++++--- .../oidc/__tests__/oidc.service.ee.test.ts | 4 +++ .../cli/src/sso.ee/oidc/oidc.service.ee.ts | 25 +++++++++++-- .../integration/oidc/oidc.service.ee.test.ts | 36 +++++++++++++------ 5 files changed, 61 insertions(+), 19 deletions(-) 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 index 25115f7eee2..e0d7b8db8a4 100644 --- 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 @@ -501,7 +501,7 @@ describe('ProvisioningService', () => { expect(config).toEqual({ ...provisioningConfigDto, scopesProvisionInstanceRole: false }); expect(provisioningService.loadConfig).toHaveBeenCalledTimes(1); expect(provisioningService.getConfig).toHaveBeenCalledTimes(2); - expect(settingsRepository.update).toHaveBeenCalledTimes(1); + expect(settingsRepository.upsert).toHaveBeenCalledTimes(1); expect(publisher.publishCommand).toHaveBeenCalledTimes(1); expect(publisher.publishCommand).toHaveBeenCalledWith({ command: 'reload-sso-provisioning-configuration', diff --git a/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts b/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts index 5e4e01dc461..4be49ceb005 100644 --- a/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts +++ b/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts @@ -254,13 +254,18 @@ export class ProvisioningService { ProvisioningConfigDto.parse(updatedConfig); - await this.settingsRepository.update( - { key: PROVISIONING_PREFERENCES_DB_KEY }, - { value: JSON.stringify(updatedConfig) }, + await this.settingsRepository.upsert( + { + key: PROVISIONING_PREFERENCES_DB_KEY, + value: JSON.stringify(updatedConfig), + loadOnStartup: true, + }, + { conflictPaths: ['key'] }, ); - await this.publisher.publishCommand({ command: 'reload-sso-provisioning-configuration' }); this.provisioningConfig = await this.loadConfig(); + + await this.publisher.publishCommand({ command: 'reload-sso-provisioning-configuration' }); return await this.getConfig(); } diff --git a/packages/cli/src/sso.ee/oidc/__tests__/oidc.service.ee.test.ts b/packages/cli/src/sso.ee/oidc/__tests__/oidc.service.ee.test.ts index 6e1954e5397..586949390f9 100644 --- a/packages/cli/src/sso.ee/oidc/__tests__/oidc.service.ee.test.ts +++ b/packages/cli/src/sso.ee/oidc/__tests__/oidc.service.ee.test.ts @@ -16,6 +16,7 @@ import { OIDC_PREFERENCES_DB_KEY } from '../constants'; import { OidcService } from '../oidc.service.ee'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import type { OidcConfigDto } from '@n8n/api-types'; +import { type ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; describe('OidcService', () => { let oidcService: OidcService; @@ -25,6 +26,7 @@ describe('OidcService', () => { let cipher: Cipher; let logger: Logger; let jwtService: JwtService; + let provisioningService: ProvisioningService; const mockOidcConfig = { clientId: 'test-client-id', @@ -56,6 +58,7 @@ describe('OidcService', () => { cipher = mock(); logger = mockLogger(); jwtService = mock(); + provisioningService = mock(); jest .spyOn(ssoHelpers, 'setCurrentAuthenticationMethod') @@ -71,6 +74,7 @@ describe('OidcService', () => { logger, jwtService, instanceSettings, + provisioningService, ); await oidcService.init(); diff --git a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts index 1cefba9ef07..a314c8f4f59 100644 --- a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts +++ b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts @@ -31,6 +31,7 @@ import { } from '../sso-helpers'; import { OIDC_CLIENT_SECRET_REDACTED_VALUE, OIDC_PREFERENCES_DB_KEY } from './constants'; import { OnPubSubEvent } from '@n8n/decorators'; +import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; const DEFAULT_OIDC_CONFIG: OidcConfigDto = { clientId: '', @@ -66,6 +67,7 @@ export class OidcService { private readonly logger: Logger, private readonly jwtService: JwtService, private readonly instanceSettings: InstanceSettings, + private readonly provisioningService: ProvisioningService, ) {} async init() { @@ -176,13 +178,14 @@ export class OidcService { const prompt = this.oidcConfig.prompt; - const provisioning = this.globalConfig.sso.provisioning; + const provisioningConfig = await this.provisioningService.getConfig(); const provisioningEnabled = - provisioning.scopesProvisionInstanceRole || provisioning.scopesProvisionProjectRoles; + provisioningConfig.scopesProvisionInstanceRole || + provisioningConfig.scopesProvisionProjectRoles; // Include the custom n8n scope if provisioning is enabled const scope = provisioningEnabled - ? `openid email profile ${provisioning.scopesName}` + ? `openid email profile ${provisioningConfig.scopesName}` : 'openid email profile'; const authorizationURL = client.buildAuthorizationUrl(configuration, { @@ -242,6 +245,11 @@ export class OidcService { throw new BadRequestError('Invalid email format'); } + const provisioningConfig = await this.provisioningService.getConfig(); + const provisioningEnabled = + provisioningConfig.scopesProvisionInstanceRole || + provisioningConfig.scopesProvisionProjectRoles; + const openidUser = await this.authIdentityRepository.findOne({ where: { providerId: claims.sub, providerType: 'oidc' }, relations: { @@ -252,6 +260,13 @@ export class OidcService { }); if (openidUser) { + if (provisioningEnabled) { + await this.provisioningService.provisionInstanceRoleForUser( + openidUser.user, + claims.n8n_instance_role, + ); + } + return openidUser.user; } @@ -297,6 +312,10 @@ export class OidcService { }), ); + if (provisioningEnabled) { + await this.provisioningService.provisionInstanceRoleForUser(user, claims.n8n_instance_role); + } + return user; }); } diff --git a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts index dfa537964f6..5ee40a09deb 100644 --- a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts +++ b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts @@ -24,6 +24,7 @@ import { createUser } from '@test-integration/db/users'; import { UserError } from 'n8n-workflow'; import { JwtService } from '@/services/jwt.service'; import { GlobalConfig } from '@n8n/config'; +import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; beforeAll(async () => { await testDb.init(); @@ -334,8 +335,12 @@ describe('OIDC service', () => { }; it('should not include the provisioning scope if no provisioning is enabled', async () => { - Container.get(GlobalConfig).sso.provisioning.scopesProvisionProjectRoles = false; - Container.get(GlobalConfig).sso.provisioning.scopesProvisionInstanceRole = false; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = false; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = false; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesName = 'n8n_test_scope'; const authUrl = await oidcService.generateLoginUrl(); validateUrl(authUrl); @@ -343,9 +348,12 @@ describe('OIDC service', () => { }); it('should include the provisioning scope if project provisioning is enabled', async () => { - Container.get(GlobalConfig).sso.provisioning.scopesProvisionProjectRoles = true; - Container.get(GlobalConfig).sso.provisioning.scopesProvisionInstanceRole = false; - Container.get(GlobalConfig).sso.provisioning.scopesName = 'n8n_test_scope'; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = true; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = false; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesName = 'n8n_test_scope'; const authUrl = await oidcService.generateLoginUrl(); validateUrl(authUrl); @@ -353,9 +361,12 @@ describe('OIDC service', () => { }); it('should include the provisioning scope if instance provisioning is enabled', async () => { - Container.get(GlobalConfig).sso.provisioning.scopesProvisionProjectRoles = false; - Container.get(GlobalConfig).sso.provisioning.scopesProvisionInstanceRole = true; - Container.get(GlobalConfig).sso.provisioning.scopesName = 'n8n_test_scope'; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = false; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = true; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesName = 'n8n_test_scope'; const authUrl = await oidcService.generateLoginUrl(); validateUrl(authUrl); @@ -363,9 +374,12 @@ describe('OIDC service', () => { }); it('should include the provisioning scope if project and instance provisioning is enabled', async () => { - Container.get(GlobalConfig).sso.provisioning.scopesProvisionProjectRoles = true; - Container.get(GlobalConfig).sso.provisioning.scopesProvisionInstanceRole = true; - Container.get(GlobalConfig).sso.provisioning.scopesName = 'n8n_test_scope'; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = true; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = true; + // @ts-expect-error - provisioningConfig is private and only accessible within the class + Container.get(ProvisioningService).provisioningConfig.scopesName = 'n8n_test_scope'; const authUrl = await oidcService.generateLoginUrl(); validateUrl(authUrl);