feat: Wire up saml to instance role provisioning (#21080)

This commit is contained in:
Stephen Wright
2025-10-23 16:34:30 +02:00
committed by GitHub
parent 1e2bd08edc
commit b8a62c893f
12 changed files with 154 additions and 2 deletions
@@ -11,6 +11,7 @@ describe('SamlPreferences', () => {
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'johndoe',
n8nInstanceRole: 'n8n_instance_role',
},
metadata: '<xml>metadata</xml>',
metadataUrl: 'https://example.com/metadata',
@@ -26,6 +27,7 @@ describe('SamlPreferences', () => {
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'johndoe',
n8nInstanceRole: 'n8n_instance_role',
},
metadata: '<xml>metadata</xml>',
metadataUrl: 'https://example.com/metadata',
@@ -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. */
@@ -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;
});
});
});
@@ -307,4 +307,22 @@ export class ProvisioningService {
return envProvidedConfig;
}
async isProvisioningEnabled(): Promise<boolean> {
const provisioningConfig = await this.getConfig();
return (
provisioningConfig.scopesProvisionInstanceRole ||
provisioningConfig.scopesProvisionProjectRoles
);
}
async isInstanceRoleProvisioningEnabled(): Promise<boolean> {
const provisioningConfig = await this.getConfig();
return provisioningConfig.scopesProvisionInstanceRole;
}
async isProjectRolesProvisioningEnabled(): Promise<boolean> {
const provisioningConfig = await this.getConfig();
return provisioningConfig.scopesProvisionProjectRoles;
}
}
@@ -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);
@@ -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<AuthService>();
const samlService = mock<SamlService>();
const urlService = mock<UrlService>();
const eventService = mock<EventService>();
const controller = new SamlController(authService, samlService, urlService, eventService);
const provisioningService = mock<ProvisioningService>();
const controller = new SamlController(
authService,
samlService,
urlService,
eventService,
provisioningService,
);
const user = mock<User>({
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', () => {
@@ -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 {
@@ -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);
@@ -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>(samlPreferences.value);
const provisioningConfig = jsonParse<ProvisioningConfigDto>(
provisioningConfigObject?.value ?? '{}',
);
if (prefs && prefs.mapping) {
prefs.mapping.n8nInstanceRole = provisioningConfig.scopesInstanceRoleClaimName;
}
if (prefs) {
if (apply) {
await this.setSamlPreferences(prefs, true, broadcastReload);
@@ -22,6 +22,7 @@ describe('sso/saml/samlHelpers', () => {
lastName: 'Nathaniel',
email: 'nathan@n8n.io',
userPrincipalName: 'Huh?',
n8nInstanceRole: 'n8n_instance_role',
};
//
@@ -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;
@@ -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: '',