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: '',