diff --git a/packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts b/packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts index 6d11483347d..f6171efc54b 100644 --- a/packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/saml/__tests__/saml-preferences.dto.test.ts @@ -11,6 +11,7 @@ describe('SamlPreferences', () => { firstName: 'John', lastName: 'Doe', userPrincipalName: 'johndoe', + n8nInstanceRole: 'n8n_instance_role', }, metadata: 'metadata', metadataUrl: 'https://example.com/metadata', @@ -26,6 +27,7 @@ describe('SamlPreferences', () => { firstName: 'John', lastName: 'Doe', userPrincipalName: 'johndoe', + n8nInstanceRole: 'n8n_instance_role', }, metadata: 'metadata', metadataUrl: 'https://example.com/metadata', diff --git a/packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts b/packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts index e07504c1b34..36178b75117 100644 --- a/packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts +++ b/packages/@n8n/api-types/src/dto/saml/saml-preferences.dto.ts @@ -20,6 +20,7 @@ export class SamlPreferences extends Z.class({ firstName: z.string(), lastName: z.string(), userPrincipalName: z.string(), + n8nInstanceRole: z.string(), }) .optional(), /** SAML metadata in XML format. */ 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 e0d7b8db8a4..f6aca3842e6 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 @@ -511,4 +511,88 @@ describe('ProvisioningService', () => { provisioningService.getConfig = originStateGetConfig; }); }); + + describe('isProvisioningEnabled', () => { + it('should return true if the provisioning config is enabled', async () => { + const originStateGetConfig = provisioningService.getConfig; + + const provisioningConfig = { + ...provisioningConfigDto, + scopesProvisionInstanceRole: true, + scopesProvisionProjectRoles: true, + }; + provisioningService.getConfig = jest.fn().mockResolvedValue(provisioningConfig); + const isProvisioningEnabled = await provisioningService.isProvisioningEnabled(); + expect(isProvisioningEnabled).toBe(true); + + provisioningService.getConfig = originStateGetConfig; + }); + + it('should return false if the provisioning config is not enabled', async () => { + const originStateGetConfig = provisioningService.getConfig; + + const provisioningConfig = { + ...provisioningConfigDto, + scopesProvisionInstanceRole: false, + scopesProvisionProjectRoles: false, + }; + provisioningService.getConfig = jest.fn().mockResolvedValue(provisioningConfig); + const isProvisioningEnabled = await provisioningService.isProvisioningEnabled(); + expect(isProvisioningEnabled).toBe(false); + + provisioningService.getConfig = originStateGetConfig; + }); + }); + + describe('isInstanceRoleProvisioningEnabled', () => { + it('should return true if the instance role provisioning config is enabled', async () => { + const originStateGetConfig = provisioningService.getConfig; + + const provisioningConfig = { ...provisioningConfigDto, scopesProvisionInstanceRole: true }; + provisioningService.getConfig = jest.fn().mockResolvedValue(provisioningConfig); + const isInstanceRoleProvisioningEnabled = + await provisioningService.isInstanceRoleProvisioningEnabled(); + expect(isInstanceRoleProvisioningEnabled).toBe(true); + + provisioningService.getConfig = originStateGetConfig; + }); + + it('should return false if the instance role provisioning config is not enabled', async () => { + const originStateGetConfig = provisioningService.getConfig; + + const provisioningConfig = { ...provisioningConfigDto, scopesProvisionInstanceRole: false }; + provisioningService.getConfig = jest.fn().mockResolvedValue(provisioningConfig); + const isInstanceRoleProvisioningEnabled = + await provisioningService.isInstanceRoleProvisioningEnabled(); + expect(isInstanceRoleProvisioningEnabled).toBe(false); + + provisioningService.getConfig = originStateGetConfig; + }); + }); + + describe('isProjectRolesProvisioningEnabled', () => { + it('should return true if the project roles provisioning config is enabled', async () => { + const originStateGetConfig = provisioningService.getConfig; + + const provisioningConfig = { ...provisioningConfigDto, scopesProvisionProjectRoles: true }; + provisioningService.getConfig = jest.fn().mockResolvedValue(provisioningConfig); + const isProjectRolesProvisioningEnabled = + await provisioningService.isProjectRolesProvisioningEnabled(); + expect(isProjectRolesProvisioningEnabled).toBe(true); + + provisioningService.getConfig = originStateGetConfig; + }); + + it('should return false if the project roles provisioning config is not enabled', async () => { + const originStateGetConfig = provisioningService.getConfig; + + const provisioningConfig = { ...provisioningConfigDto, scopesProvisionProjectRoles: false }; + provisioningService.getConfig = jest.fn().mockResolvedValue(provisioningConfig); + const isProjectRolesProvisioningEnabled = + await provisioningService.isProjectRolesProvisioningEnabled(); + expect(isProjectRolesProvisioningEnabled).toBe(false); + + provisioningService.getConfig = originStateGetConfig; + }); + }); }); 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 4be49ceb005..cdf98574d71 100644 --- a/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts +++ b/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts @@ -307,4 +307,22 @@ export class ProvisioningService { return envProvidedConfig; } + + async isProvisioningEnabled(): Promise { + const provisioningConfig = await this.getConfig(); + return ( + provisioningConfig.scopesProvisionInstanceRole || + provisioningConfig.scopesProvisionProjectRoles + ); + } + + async isInstanceRoleProvisioningEnabled(): Promise { + const provisioningConfig = await this.getConfig(); + return provisioningConfig.scopesProvisionInstanceRole; + } + + async isProjectRolesProvisioningEnabled(): Promise { + const provisioningConfig = await this.getConfig(); + return provisioningConfig.scopesProvisionProjectRoles; + } } diff --git a/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts b/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts index bbb4f5470cb..c6d2bc8fa0b 100644 --- a/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts +++ b/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts @@ -27,6 +27,7 @@ describe('sso/saml/samlHelpers', () => { lastName: 'Nathaniel', email: 'n@8.n', userPrincipalName: 'Huh?', + n8nInstanceRole: 'n8n_instance_role', }; userRepository.findOne.mockImplementationOnce(async (_) => user); diff --git a/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts b/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts index bbafc277a10..8754f9fbf85 100644 --- a/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts +++ b/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts @@ -19,12 +19,20 @@ jest.mock('../../saml-helpers', () => ({ })); import { isConnectionTestRequest, isSamlLicensedAndEnabled } from '../../saml-helpers'; +import { type ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; const authService = mock(); const samlService = mock(); const urlService = mock(); const eventService = mock(); -const controller = new SamlController(authService, samlService, urlService, eventService); +const provisioningService = mock(); +const controller = new SamlController( + authService, + samlService, + urlService, + eventService, + provisioningService, +); const user = mock({ id: '123', @@ -38,6 +46,7 @@ const attributes: SamlUserAttributes = { firstName: 'Test', lastName: 'User', userPrincipalName: 'upn:test@example.com', + n8nInstanceRole: 'n8n_instance_role', }; describe('Test views', () => { diff --git a/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts index ff665acda76..fca236b2331 100644 --- a/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts @@ -26,6 +26,7 @@ import { } from '../service-provider.ee'; import type { SamlLoginBinding } from '../types'; import { getInitSSOFormView } from '../views/init-sso-post'; +import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; @RestController('/sso/saml') export class SamlController { @@ -34,6 +35,7 @@ export class SamlController { private readonly samlService: SamlService, private readonly urlService: UrlService, private readonly eventService: EventService, + private readonly provisioningService: ProvisioningService, ) {} @Get('/metadata', { skipAuth: true }) @@ -128,6 +130,17 @@ export class SamlController { // Only sign in user if SAML is enabled, otherwise treat as test connection if (isSamlLicensedAndEnabled()) { this.authService.issueCookie(res, loginResult.authenticatedUser, true, req.browserId); + + const isRoleProvisioningEnabled = + await this.provisioningService.isInstanceRoleProvisioningEnabled(); + + if (isRoleProvisioningEnabled && loginResult.attributes.n8nInstanceRole) { + await this.provisioningService.provisionInstanceRoleForUser( + loginResult.authenticatedUser, + loginResult.attributes.n8nInstanceRole, + ); + } + if (loginResult.onboardingRequired) { return res.redirect(this.urlService.getInstanceBaseUrl() + '/saml/onboarding'); } else { diff --git a/packages/cli/src/sso.ee/saml/saml-helpers.ts b/packages/cli/src/sso.ee/saml/saml-helpers.ts index 52079b1fb5a..3c03d49fddc 100644 --- a/packages/cli/src/sso.ee/saml/saml-helpers.ts +++ b/packages/cli/src/sso.ee/saml/saml-helpers.ts @@ -150,12 +150,14 @@ export function getMappedSamlAttributesFromFlowResult( const firstName = attributes[attributeMapping.firstName]; const lastName = attributes[attributeMapping.lastName]; const userPrincipalName = attributes[attributeMapping.userPrincipalName]; + const n8nInstanceRole = attributes[attributeMapping.n8nInstanceRole]; result.attributes = { email, firstName, lastName, userPrincipalName, + n8nInstanceRole, }; if (!email) result.missingAttributes.push(attributeMapping.email); if (!userPrincipalName) result.missingAttributes.push(attributeMapping.userPrincipalName); diff --git a/packages/cli/src/sso.ee/saml/saml.service.ee.ts b/packages/cli/src/sso.ee/saml/saml.service.ee.ts index 084b702ca3c..08724f2c7fc 100644 --- a/packages/cli/src/sso.ee/saml/saml.service.ee.ts +++ b/packages/cli/src/sso.ee/saml/saml.service.ee.ts @@ -1,4 +1,4 @@ -import type { SamlPreferences } from '@n8n/api-types'; +import type { ProvisioningConfigDto, SamlPreferences } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import type { Settings, User } from '@n8n/db'; @@ -34,6 +34,7 @@ import { SamlValidator } from './saml-validator'; import { getServiceProviderInstance } from './service-provider.ee'; import type { SamlLoginBinding, SamlUserAttributes } from './types'; import { isSsoJustInTimeProvisioningEnabled, reloadAuthenticationMethod } from '../sso-helpers'; +import { PROVISIONING_PREFERENCES_DB_KEY } from '@/modules/provisioning.ee/constants'; @Service() export class SamlService { @@ -48,6 +49,8 @@ export class SamlService { firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname', lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastname', userPrincipalName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', + // this value is loaded on init from the provisioning config + n8nInstanceRole: '', }, metadata: '', metadataUrl: '', @@ -199,6 +202,7 @@ export class SamlService { onboardingRequired: boolean; }> { const attributes = await this.getAttributesFromLoginResponse(req, binding); + if (attributes.email) { const lowerCasedEmail = attributes.email.toLowerCase(); @@ -244,6 +248,7 @@ export class SamlService { } } } + return { authenticatedUser: undefined, attributes, @@ -384,8 +389,19 @@ export class SamlService { const samlPreferences = await this.settingsRepository.findOne({ where: { key: SAML_PREFERENCES_DB_KEY }, }); + const provisioningConfigObject = await this.settingsRepository.findOne({ + where: { key: PROVISIONING_PREFERENCES_DB_KEY }, + }); if (samlPreferences) { const prefs = jsonParse(samlPreferences.value); + const provisioningConfig = jsonParse( + provisioningConfigObject?.value ?? '{}', + ); + + if (prefs && prefs.mapping) { + prefs.mapping.n8nInstanceRole = provisioningConfig.scopesInstanceRoleClaimName; + } + if (prefs) { if (apply) { await this.setSamlPreferences(prefs, true, broadcastReload); diff --git a/packages/cli/test/integration/saml/saml-helpers.test.ts b/packages/cli/test/integration/saml/saml-helpers.test.ts index c7aba8c7f26..5f61723297c 100644 --- a/packages/cli/test/integration/saml/saml-helpers.test.ts +++ b/packages/cli/test/integration/saml/saml-helpers.test.ts @@ -22,6 +22,7 @@ describe('sso/saml/samlHelpers', () => { lastName: 'Nathaniel', email: 'nathan@n8n.io', userPrincipalName: 'Huh?', + n8nInstanceRole: 'n8n_instance_role', }; // diff --git a/packages/cli/test/integration/saml/saml.api.test.ts b/packages/cli/test/integration/saml/saml.api.test.ts index cb40b1ae3f5..f5dfce4eb8d 100644 --- a/packages/cli/test/integration/saml/saml.api.test.ts +++ b/packages/cli/test/integration/saml/saml.api.test.ts @@ -301,6 +301,7 @@ describe('SAML email validation', () => { firstName: 'John', lastName: 'Doe', userPrincipalName: 'john.doe', + n8nInstanceRole: 'n8n_instance_role', }); const mockRequest = {} as express.Request; @@ -318,6 +319,7 @@ describe('SAML email validation', () => { firstName: 'John', lastName: 'Doe', userPrincipalName: 'john.doe', + n8nInstanceRole: 'n8n_instance_role', }); const mockRequest = {} as express.Request; @@ -341,6 +343,7 @@ describe('SAML email validation', () => { firstName: 'John', lastName: 'Doe', userPrincipalName: 'john.doe', + n8nInstanceRole: 'n8n_instance_role', }); // Should not throw an error for valid emails @@ -357,6 +360,7 @@ describe('SAML email validation', () => { firstName: 'John', lastName: 'Doe', userPrincipalName: 'john.doe', + n8nInstanceRole: 'n8n_instance_role', }); const mockRequest = {} as express.Request; diff --git a/packages/cli/test/integration/saml/sample-metadata.ts b/packages/cli/test/integration/saml/sample-metadata.ts index 528a3f158fd..60f18426aa5 100644 --- a/packages/cli/test/integration/saml/sample-metadata.ts +++ b/packages/cli/test/integration/saml/sample-metadata.ts @@ -8,6 +8,7 @@ export const sampleConfig: SamlPreferences = { firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname', lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastname', userPrincipalName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', + n8nInstanceRole: 'n8n_instance_role', }, metadata: sampleMetadata, metadataUrl: '',