mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
chore(core): Synchronize SAML settings in multi-main (#19588)
This commit is contained in:
@@ -17,6 +17,7 @@ export type PubSubEventName =
|
|||||||
| 'reload-external-secrets-providers'
|
| 'reload-external-secrets-providers'
|
||||||
| 'reload-license'
|
| 'reload-license'
|
||||||
| 'reload-oidc-config'
|
| 'reload-oidc-config'
|
||||||
|
| 'reload-saml-config'
|
||||||
| 'response-to-get-worker-status'
|
| 'response-to-get-worker-status'
|
||||||
| 'restart-event-bus'
|
| 'restart-event-bus'
|
||||||
| 'relay-execution-lifecycle-event';
|
| 'relay-execution-lifecycle-event';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type PubSubCommandMap = {
|
|||||||
// # region SSO
|
// # region SSO
|
||||||
|
|
||||||
'reload-oidc-config': never;
|
'reload-oidc-config': never;
|
||||||
|
'reload-saml-config': never;
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export namespace PubSub {
|
|||||||
namespace Commands {
|
namespace Commands {
|
||||||
export type ReloadLicense = ToCommand<'reload-license'>;
|
export type ReloadLicense = ToCommand<'reload-license'>;
|
||||||
export type ReloadOIDCConfiguration = ToCommand<'reload-oidc-config'>;
|
export type ReloadOIDCConfiguration = ToCommand<'reload-oidc-config'>;
|
||||||
|
export type ReloadSamlConfiguration = ToCommand<'reload-saml-config'>;
|
||||||
export type RestartEventBus = ToCommand<'restart-event-bus'>;
|
export type RestartEventBus = ToCommand<'restart-event-bus'>;
|
||||||
export type ReloadExternalSecretsProviders = ToCommand<'reload-external-secrets-providers'>;
|
export type ReloadExternalSecretsProviders = ToCommand<'reload-external-secrets-providers'>;
|
||||||
export type CommunityPackageInstall = ToCommand<'community-package-install'>;
|
export type CommunityPackageInstall = ToCommand<'community-package-install'>;
|
||||||
@@ -74,7 +75,8 @@ export namespace PubSub {
|
|||||||
| Commands.DisplayWorkflowActivationError
|
| Commands.DisplayWorkflowActivationError
|
||||||
| Commands.RelayExecutionLifecycleEvent
|
| Commands.RelayExecutionLifecycleEvent
|
||||||
| Commands.ClearTestWebhooks
|
| Commands.ClearTestWebhooks
|
||||||
| Commands.ReloadOIDCConfiguration;
|
| Commands.ReloadOIDCConfiguration
|
||||||
|
| Commands.ReloadSamlConfiguration;
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
// worker responses
|
// worker responses
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { mockInstance } from '@n8n/backend-test-utils';
|
import type { SamlPreferences } from '@n8n/api-types';
|
||||||
|
import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
|
||||||
|
import type { GlobalConfig } from '@n8n/config';
|
||||||
import { SettingsRepository } from '@n8n/db';
|
import { SettingsRepository } from '@n8n/db';
|
||||||
|
import type { UserRepository } from '@n8n/db';
|
||||||
import type { Settings } from '@n8n/db';
|
import type { Settings } from '@n8n/db';
|
||||||
|
import { Container } from '@n8n/di';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type express from 'express';
|
import type express from 'express';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { InstanceSettings } from 'n8n-core';
|
||||||
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
|
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
|
||||||
|
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
|
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||||
|
import type { UrlService } from '@/services/url.service';
|
||||||
import * as samlHelpers from '@/sso.ee/saml/saml-helpers';
|
import * as samlHelpers from '@/sso.ee/saml/saml-helpers';
|
||||||
import { SamlService } from '@/sso.ee/saml/saml.service.ee';
|
import { SamlService } from '@/sso.ee/saml/saml.service.ee';
|
||||||
|
import * as ssoHelpers from '@/sso.ee/sso-helpers';
|
||||||
|
|
||||||
import { SAML_PREFERENCES_DB_KEY } from '../constants';
|
import { SAML_PREFERENCES_DB_KEY } from '../constants';
|
||||||
import { InvalidSamlMetadataUrlError } from '../errors/invalid-saml-metadata-url.error';
|
import { InvalidSamlMetadataUrlError } from '../errors/invalid-saml-metadata-url.error';
|
||||||
@@ -137,16 +145,68 @@ const SamlSettingWithValidUrl: Settings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('SamlService', () => {
|
describe('SamlService', () => {
|
||||||
|
let samlService: SamlService;
|
||||||
|
let settingsRepository: SettingsRepository;
|
||||||
|
let instanceSettings: InstanceSettings;
|
||||||
|
let globalConfig: GlobalConfig;
|
||||||
const validator = new SamlValidator(mock());
|
const validator = new SamlValidator(mock());
|
||||||
const settingsRepository = mockInstance(SettingsRepository);
|
const logger = mockLogger();
|
||||||
const samlService = new SamlService(mock(), mock(), validator, mock(), settingsRepository);
|
|
||||||
|
const mockSamlConfig = {
|
||||||
|
mapping: {
|
||||||
|
email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
metadata:
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://saml.example.com/entityid" validUntil="2035-05-07T13:33:47.181Z">\n <md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">\n <md:KeyDescriptor use="signing">\n <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">\n <ds:X509Data>\n <ds:X509Certificate>MIIC4jCCAcoCCQC33wnybT5QZDANBgkqhkiG9w0BAQsFADAyMQswCQYDVQQGEwJV\nSzEPMA0GA1UECgwGQm94eUhRMRIwEAYDVQQDDAlNb2NrIFNBTUwwIBcNMjIwMjI4\nMjE0NjM4WhgPMzAyMTA3MDEyMTQ2MzhaMDIxCzAJBgNVBAYTAlVLMQ8wDQYDVQQK\nDAZCb3h5SFExEjAQBgNVBAMMCU1vY2sgU0FNTDCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBALGfYettMsct1T6tVUwTudNJH5Pnb9GGnkXi9Zw/e6x45DD0\nRuRONbFlJ2T4RjAE/uG+AjXxXQ8o2SZfb9+GgmCHuTJFNgHoZ1nFVXCmb/Hg8Hpd\n4vOAGXndixaReOiq3EH5XvpMjMkJ3+8+9VYMzMZOjkgQtAqO36eAFFfNKX7dTj3V\npwLkvz6/KFCq8OAwY+AUi4eZm5J57D31GzjHwfjH9WTeX0MyndmnNB1qV75qQR3b\n2/W5sGHRv+9AarggJkF+ptUkXoLtVA51wcfYm6hILptpde5FQC8RWY1YrswBWAEZ\nNfyrR4JeSweElNHg4NVOs4TwGjOPwWGqzTfgTlECAwEAATANBgkqhkiG9w0BAQsF\nAAOCAQEAAYRlYflSXAWoZpFfwNiCQVE5d9zZ0DPzNdWhAybXcTyMf0z5mDf6FWBW\n5Gyoi9u3EMEDnzLcJNkwJAAc39Apa4I2/tml+Jy29dk8bTyX6m93ngmCgdLh5Za4\nkhuU3AM3L63g7VexCuO7kwkjh/+LqdcIXsVGO6XDfu2QOs1Xpe9zIzLpwm/RNYeX\nUjbSj5ce/jekpAw7qyVVL4xOyh8AtUW1ek3wIw1MJvEgEPt0d16oshWJpoS1OT8L\nr/22SvYEo3EmSGdTVGgk3x3s+A0qWAqTcyjr7Q4s/GKYRFfomGwz0TZ4Iw1ZN99M\nm0eo2USlSRTVl7QHRTuiuSThHpLKQQ==</ds:X509Certificate>\n </ds:X509Data>\n </ds:KeyInfo>\n </md:KeyDescriptor>\n <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>\n <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://mocksaml.com/api/saml/sso"/>\n <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://mocksaml.com/api/saml/sso"/>\n </md:IDPSSODescriptor>\n</md:EntityDescriptor>',
|
||||||
|
metadataUrl: '',
|
||||||
|
ignoreSSL: false,
|
||||||
|
loginBinding: 'redirect',
|
||||||
|
acsBinding: 'post',
|
||||||
|
authnRequestsSigned: false,
|
||||||
|
loginEnabled: false,
|
||||||
|
loginLabel: 'SAML',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigFromDB = {
|
||||||
|
key: SAML_PREFERENCES_DB_KEY,
|
||||||
|
value: JSON.stringify(mockSamlConfig),
|
||||||
|
loadOnStartup: true,
|
||||||
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await validator.init();
|
await validator.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
jest.restoreAllMocks();
|
jest.resetAllMocks();
|
||||||
|
Container.reset();
|
||||||
|
|
||||||
|
settingsRepository = mockInstance(SettingsRepository);
|
||||||
|
instanceSettings = mock<InstanceSettings>({
|
||||||
|
isMultiMain: true,
|
||||||
|
});
|
||||||
|
globalConfig = mock<GlobalConfig>({
|
||||||
|
sso: { saml: { loginEnabled: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(ssoHelpers, 'reloadAuthenticationMethod')
|
||||||
|
.mockImplementation(async () => await Promise.resolve());
|
||||||
|
jest.spyOn(samlHelpers, 'isSamlLoginEnabled').mockReturnValue(true);
|
||||||
|
|
||||||
|
samlService = new SamlService(
|
||||||
|
logger,
|
||||||
|
mock<UrlService>(),
|
||||||
|
validator,
|
||||||
|
mock<UserRepository>(),
|
||||||
|
settingsRepository,
|
||||||
|
instanceSettings,
|
||||||
|
);
|
||||||
|
// Mock GlobalConfig container access
|
||||||
|
Container.set(require('@n8n/config').GlobalConfig, globalConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAttributesFromLoginResponse', () => {
|
describe('getAttributesFromLoginResponse', () => {
|
||||||
@@ -257,7 +317,7 @@ describe('SamlService', () => {
|
|||||||
jest.spyOn(settingsRepository, 'findOne').mockResolvedValue(InvalidSamlSetting);
|
jest.spyOn(settingsRepository, 'findOne').mockResolvedValue(InvalidSamlSetting);
|
||||||
|
|
||||||
// ACT && ASSERT
|
// ACT && ASSERT
|
||||||
await expect(samlService.loadFromDbAndApplySamlPreferences(true)).rejects.toThrowError(
|
await expect(samlService.loadFromDbAndApplySamlPreferences(true, false)).rejects.toThrowError(
|
||||||
InvalidSamlMetadataError,
|
InvalidSamlMetadataError,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -269,7 +329,7 @@ describe('SamlService', () => {
|
|||||||
.mockResolvedValue(SamlSettingWithInvalidUrlAndInvalidMetadataXML);
|
.mockResolvedValue(SamlSettingWithInvalidUrlAndInvalidMetadataXML);
|
||||||
|
|
||||||
// ACT && ASSERT
|
// ACT && ASSERT
|
||||||
await expect(samlService.loadFromDbAndApplySamlPreferences(true)).rejects.toThrowError(
|
await expect(samlService.loadFromDbAndApplySamlPreferences(true, false)).rejects.toThrowError(
|
||||||
InvalidSamlMetadataError,
|
InvalidSamlMetadataError,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -279,7 +339,7 @@ describe('SamlService', () => {
|
|||||||
jest.spyOn(settingsRepository, 'findOne').mockResolvedValue(SamlSettingWithInvalidUrl);
|
jest.spyOn(settingsRepository, 'findOne').mockResolvedValue(SamlSettingWithInvalidUrl);
|
||||||
|
|
||||||
// ACT && ASSERT
|
// ACT && ASSERT
|
||||||
await samlService.loadFromDbAndApplySamlPreferences(true);
|
await samlService.loadFromDbAndApplySamlPreferences(true, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not throw an error when the metadata url is valid', async () => {
|
test('does not throw an error when the metadata url is valid', async () => {
|
||||||
@@ -292,7 +352,7 @@ describe('SamlService', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ACT && ASSERT
|
// ACT && ASSERT
|
||||||
await samlService.loadFromDbAndApplySamlPreferences(true);
|
await samlService.loadFromDbAndApplySamlPreferences(true, false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -412,12 +472,167 @@ describe('SamlService', () => {
|
|||||||
await samlService.loadPreferencesWithoutValidation({
|
await samlService.loadPreferencesWithoutValidation({
|
||||||
metadata: SamlMetadataWithoutRedirectBinding,
|
metadata: SamlMetadataWithoutRedirectBinding,
|
||||||
});
|
});
|
||||||
|
await samlService.loadSamlify();
|
||||||
expect(() => samlService.getIdentityProviderInstance(true)).toThrowError(
|
expect(() => samlService.getIdentityProviderInstance(true)).toThrowError(
|
||||||
InvalidSamlMetadataError,
|
InvalidSamlMetadataError,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('broadcastReloadSAMLConfigurationCommand', () => {
|
||||||
|
const mockPublisher = { publishCommand: jest.fn() };
|
||||||
|
beforeEach(() => {
|
||||||
|
mockInstance(Publisher, mockPublisher);
|
||||||
|
// Mock all the validation and setup methods that setSamlPreferences calls
|
||||||
|
jest.spyOn(samlService, 'loadSamlify').mockResolvedValue(undefined);
|
||||||
|
jest.spyOn(validator, 'validateMetadata').mockResolvedValue(true);
|
||||||
|
jest.spyOn(samlService, 'getIdentityProviderInstance').mockReturnValue({} as any);
|
||||||
|
jest
|
||||||
|
.spyOn(samlService, 'saveSamlPreferencesToDb')
|
||||||
|
.mockResolvedValue(mockSamlConfig as SamlPreferences);
|
||||||
|
// Mock SAML login as disabled to avoid metadata validation
|
||||||
|
jest.spyOn(samlHelpers, 'isSamlLoginEnabled').mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should publish reload command in multi-main setup', async () => {
|
||||||
|
(instanceSettings as any).isMultiMain = true;
|
||||||
|
|
||||||
|
await samlService.setSamlPreferences(
|
||||||
|
{ loginEnabled: false, metadata: mockSamlConfig.metadata },
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockPublisher.publishCommand).toHaveBeenCalledWith({
|
||||||
|
command: 'reload-saml-config',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not publish in single main setup', async () => {
|
||||||
|
(instanceSettings as any).isMultiMain = false;
|
||||||
|
|
||||||
|
await samlService.setSamlPreferences(
|
||||||
|
{ loginEnabled: false, metadata: mockSamlConfig.metadata },
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockPublisher.publishCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not publish when broadcastReload is false', async () => {
|
||||||
|
(instanceSettings as any).isMultiMain = true;
|
||||||
|
|
||||||
|
await samlService.setSamlPreferences(
|
||||||
|
{ loginEnabled: false, metadata: mockSamlConfig.metadata },
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockPublisher.publishCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reload', () => {
|
||||||
|
test('should reload SAML configuration from database', async () => {
|
||||||
|
settingsRepository.findOne = jest.fn().mockResolvedValue(mockConfigFromDB);
|
||||||
|
jest
|
||||||
|
.spyOn(samlService, 'loadFromDbAndApplySamlPreferences')
|
||||||
|
.mockResolvedValue(mockSamlConfig as SamlPreferences);
|
||||||
|
|
||||||
|
await samlService.reload();
|
||||||
|
|
||||||
|
expect(samlService.loadFromDbAndApplySamlPreferences).toHaveBeenCalledWith(true, false);
|
||||||
|
expect(ssoHelpers.reloadAuthenticationMethod).toHaveBeenCalled();
|
||||||
|
expect(globalConfig.sso.saml.loginEnabled).toBe(true);
|
||||||
|
expect(logger.debug).toHaveBeenCalledWith(
|
||||||
|
'SAML configuration changed, starting to load it from the database',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should prevent concurrent reloads with isReloading flag', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(samlService, 'loadFromDbAndApplySamlPreferences')
|
||||||
|
.mockResolvedValue(mockSamlConfig as SamlPreferences);
|
||||||
|
|
||||||
|
// Start first reload without awaiting
|
||||||
|
const firstReload = samlService.reload();
|
||||||
|
// Start second reload immediately
|
||||||
|
const secondReload = samlService.reload();
|
||||||
|
|
||||||
|
await Promise.all([firstReload, secondReload]);
|
||||||
|
|
||||||
|
// Should have called loadFromDbAndApplySamlPreferences only once due to isReloading flag
|
||||||
|
expect(samlService.loadFromDbAndApplySamlPreferences).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith('SAML configuration reload already in progress');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle errors during reload gracefully', async () => {
|
||||||
|
const error = new Error('Database connection failed');
|
||||||
|
jest.spyOn(samlService, 'loadFromDbAndApplySamlPreferences').mockRejectedValue(error);
|
||||||
|
|
||||||
|
await samlService.reload();
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
'SAML configuration changed, failed to reload SAML configuration',
|
||||||
|
{ error },
|
||||||
|
);
|
||||||
|
// Should reset isReloading flag even on error
|
||||||
|
// Test by calling reload again - should not be blocked
|
||||||
|
jest
|
||||||
|
.spyOn(samlService, 'loadFromDbAndApplySamlPreferences')
|
||||||
|
.mockResolvedValue(mockSamlConfig as SamlPreferences);
|
||||||
|
|
||||||
|
await samlService.reload();
|
||||||
|
expect(samlService.loadFromDbAndApplySamlPreferences).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update GlobalConfig with login status', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(samlService, 'loadFromDbAndApplySamlPreferences')
|
||||||
|
.mockResolvedValue(mockSamlConfig as SamlPreferences);
|
||||||
|
// Mock SAML as disabled
|
||||||
|
jest.spyOn(samlHelpers, 'isSamlLoginEnabled').mockReturnValue(false);
|
||||||
|
|
||||||
|
await samlService.reload();
|
||||||
|
|
||||||
|
expect(globalConfig.sso.saml.loginEnabled).toBe(false);
|
||||||
|
expect(logger.debug).toHaveBeenCalledWith('SAML login is now disabled.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadFromDbAndApplySamlPreferences with broadcastReload parameter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock required methods to avoid complex initialization
|
||||||
|
jest.spyOn(samlService, 'loadSamlify').mockResolvedValue(undefined);
|
||||||
|
jest.spyOn(samlService, 'getIdentityProviderInstance').mockReturnValue({} as any);
|
||||||
|
jest
|
||||||
|
.spyOn(samlService, 'saveSamlPreferencesToDb')
|
||||||
|
.mockResolvedValue(mockSamlConfig as SamlPreferences);
|
||||||
|
jest
|
||||||
|
.spyOn(samlService as any, 'broadcastReloadSAMLConfigurationCommand')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call setSamlPreferences with broadcastReload=true by default', async () => {
|
||||||
|
settingsRepository.findOne = jest.fn().mockResolvedValue(mockConfigFromDB);
|
||||||
|
jest.spyOn(samlService, 'setSamlPreferences');
|
||||||
|
|
||||||
|
await samlService.loadFromDbAndApplySamlPreferences(true);
|
||||||
|
|
||||||
|
expect(samlService.setSamlPreferences).toHaveBeenCalledWith(mockSamlConfig, true, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call setSamlPreferences with broadcastReload=false when specified', async () => {
|
||||||
|
settingsRepository.findOne = jest.fn().mockResolvedValue(mockConfigFromDB);
|
||||||
|
jest.spyOn(samlService, 'setSamlPreferences');
|
||||||
|
|
||||||
|
await samlService.loadFromDbAndApplySamlPreferences(true, false);
|
||||||
|
|
||||||
|
expect(samlService.setSamlPreferences).toHaveBeenCalledWith(mockSamlConfig, true, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('reset', () => {
|
describe('reset', () => {
|
||||||
test('disables saml login and deletes the saml `features.saml` key in the db', async () => {
|
test('disables saml login and deletes the saml `features.saml` key in the db', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import type { SamlPreferences } from '@n8n/api-types';
|
import type { SamlPreferences } from '@n8n/api-types';
|
||||||
import { Logger } from '@n8n/backend-common';
|
import { Logger } from '@n8n/backend-common';
|
||||||
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import type { Settings, User } from '@n8n/db';
|
import type { Settings, User } from '@n8n/db';
|
||||||
import { isValidEmail, SettingsRepository, UserRepository } from '@n8n/db';
|
import { isValidEmail, SettingsRepository, UserRepository } from '@n8n/db';
|
||||||
import { Service } from '@n8n/di';
|
import { OnPubSubEvent } from '@n8n/decorators';
|
||||||
|
import { Container, Service } from '@n8n/di';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type express from 'express';
|
import type express from 'express';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
|
import { InstanceSettings } from 'n8n-core';
|
||||||
import { jsonParse, UnexpectedError } from 'n8n-workflow';
|
import { jsonParse, UnexpectedError } from 'n8n-workflow';
|
||||||
import { type IdentityProviderInstance, type ServiceProviderInstance } from 'samlify';
|
import { type IdentityProviderInstance, type ServiceProviderInstance } from 'samlify';
|
||||||
import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity';
|
import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity';
|
||||||
@@ -30,7 +33,7 @@ import {
|
|||||||
import { SamlValidator } from './saml-validator';
|
import { SamlValidator } from './saml-validator';
|
||||||
import { getServiceProviderInstance } from './service-provider.ee';
|
import { getServiceProviderInstance } from './service-provider.ee';
|
||||||
import type { SamlLoginBinding, SamlUserAttributes } from './types';
|
import type { SamlLoginBinding, SamlUserAttributes } from './types';
|
||||||
import { isSsoJustInTimeProvisioningEnabled } from '../sso-helpers';
|
import { isSsoJustInTimeProvisioningEnabled, reloadAuthenticationMethod } from '../sso-helpers';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class SamlService {
|
export class SamlService {
|
||||||
@@ -80,6 +83,7 @@ export class SamlService {
|
|||||||
private readonly validator: SamlValidator,
|
private readonly validator: SamlValidator,
|
||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
private readonly settingsRepository: SettingsRepository,
|
private readonly settingsRepository: SettingsRepository,
|
||||||
|
private readonly instanceSettings: InstanceSettings,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
@@ -247,9 +251,46 @@ export class SamlService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async broadcastReloadSAMLConfigurationCommand(): Promise<void> {
|
||||||
|
if (this.instanceSettings.isMultiMain) {
|
||||||
|
const { Publisher } = await import('@/scaling/pubsub/publisher.service');
|
||||||
|
await Container.get(Publisher).publishCommand({ command: 'reload-saml-config' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isReloading = false;
|
||||||
|
|
||||||
|
@OnPubSubEvent('reload-saml-config')
|
||||||
|
async reload(): Promise<void> {
|
||||||
|
if (this.isReloading) {
|
||||||
|
this.logger.warn('SAML configuration reload already in progress');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isReloading = true;
|
||||||
|
try {
|
||||||
|
this.logger.debug('SAML configuration changed, starting to load it from the database');
|
||||||
|
await this.loadFromDbAndApplySamlPreferences(true, false);
|
||||||
|
|
||||||
|
await reloadAuthenticationMethod();
|
||||||
|
|
||||||
|
const samlLoginEnabled = isSamlLoginEnabled();
|
||||||
|
|
||||||
|
this.logger.debug(`SAML login is now ${samlLoginEnabled ? 'enabled' : 'disabled'}.`);
|
||||||
|
|
||||||
|
Container.get(GlobalConfig).sso.saml.loginEnabled = samlLoginEnabled;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('SAML configuration changed, failed to reload SAML configuration', {
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.isReloading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async setSamlPreferences(
|
async setSamlPreferences(
|
||||||
prefs: Partial<SamlPreferences>,
|
prefs: Partial<SamlPreferences>,
|
||||||
tryFallback: boolean = false,
|
tryFallback: boolean = false,
|
||||||
|
broadcastReload: boolean = true,
|
||||||
): Promise<SamlPreferences | undefined> {
|
): Promise<SamlPreferences | undefined> {
|
||||||
await this.loadSamlify();
|
await this.loadSamlify();
|
||||||
const previousMetadataUrl = this._samlPreferences.metadataUrl;
|
const previousMetadataUrl = this._samlPreferences.metadataUrl;
|
||||||
@@ -304,6 +345,10 @@ export class SamlService {
|
|||||||
}
|
}
|
||||||
this.getIdentityProviderInstance(true);
|
this.getIdentityProviderInstance(true);
|
||||||
const result = await this.saveSamlPreferencesToDb();
|
const result = await this.saveSamlPreferencesToDb();
|
||||||
|
|
||||||
|
if (broadcastReload) {
|
||||||
|
await this.broadcastReloadSAMLConfigurationCommand();
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +377,10 @@ export class SamlService {
|
|||||||
setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel());
|
setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel());
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadFromDbAndApplySamlPreferences(apply = true): Promise<SamlPreferences | undefined> {
|
async loadFromDbAndApplySamlPreferences(
|
||||||
|
apply = true,
|
||||||
|
broadcastReload: boolean = true,
|
||||||
|
): Promise<SamlPreferences | undefined> {
|
||||||
const samlPreferences = await this.settingsRepository.findOne({
|
const samlPreferences = await this.settingsRepository.findOne({
|
||||||
where: { key: SAML_PREFERENCES_DB_KEY },
|
where: { key: SAML_PREFERENCES_DB_KEY },
|
||||||
});
|
});
|
||||||
@@ -340,7 +388,7 @@ export class SamlService {
|
|||||||
const prefs = jsonParse<SamlPreferences>(samlPreferences.value);
|
const prefs = jsonParse<SamlPreferences>(samlPreferences.value);
|
||||||
if (prefs) {
|
if (prefs) {
|
||||||
if (apply) {
|
if (apply) {
|
||||||
await this.setSamlPreferences(prefs, true);
|
await this.setSamlPreferences(prefs, true, broadcastReload);
|
||||||
} else {
|
} else {
|
||||||
await this.loadPreferencesWithoutValidation(prefs);
|
await this.loadPreferencesWithoutValidation(prefs);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user