mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat: Wire up saml to instance role provisioning (#21080)
This commit is contained in:
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user