feat: Add GET /sso/provisioning/config endpoint for sso provisioning config (#20850)

This commit is contained in:
Stephen Wright 2025-10-17 09:04:06 +01:00 committed by GitHub
parent 697a144338
commit c40aaa5575
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 341 additions and 6 deletions

View File

@ -98,3 +98,5 @@ export { CreateDataTableColumnDto } from './data-table/create-data-table-column.
export { AddDataTableRowsDto } from './data-table/add-data-table-rows.dto';
export { AddDataTableColumnDto } from './data-table/add-data-table-column.dto';
export { MoveDataTableColumnDto } from './data-table/move-data-table-column.dto';
export { ProvisioningConfigDto } from './provisioning/config.dto';

View File

@ -0,0 +1,11 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class ProvisioningConfigDto extends Z.class({
scopesProvisionInstanceRole: z.boolean(),
scopesProvisionProjectRoles: z.boolean(),
scopesProvisioningFrequency: z.enum(['never', 'first_login', 'every_login']),
scopesName: z.string(),
scopesInstanceRoleClaimName: z.string(),
scopesProjectsRolesClaimName: z.string(),
}) {}

View File

@ -26,11 +26,22 @@ export class LicenseState {
// --------------------
// core queries
// --------------------
isLicensed(feature: BooleanLicenseFeature) {
/*
* If the feature is a string. checks if the feature is licensed
* If the feature is an array of strings, it checks if any of the features are licensed
*/
isLicensed(feature: BooleanLicenseFeature | BooleanLicenseFeature[]) {
this.assertProvider();
return this.licenseProvider.isLicensed(feature);
if (typeof feature === 'string') return this.licenseProvider.isLicensed(feature);
for (const featureName of feature) {
if (this.licenseProvider.isLicensed(featureName)) {
return true;
}
}
return false;
}
getValue<T extends keyof FeatureReturnType>(feature: T): FeatureReturnType[T] {
@ -167,6 +178,10 @@ export class LicenseState {
return this.isLicensed('feat:workflowDiffs');
}
isProvisioningLicensed() {
return this.isLicensed(['feat:saml', 'feat:oidc', 'feat:ldap']);
}
// --------------------
// integers
// --------------------

View File

@ -27,6 +27,7 @@ describe('eligibleModules', () => {
'external-secrets',
'community-packages',
'data-table',
'provisioning',
]);
});
@ -37,6 +38,7 @@ describe('eligibleModules', () => {
'external-secrets',
'community-packages',
'data-table',
'provisioning',
]);
});

View File

@ -33,6 +33,7 @@ export class ModuleRegistry {
'external-secrets',
'community-packages',
'data-table',
'provisioning',
];
private readonly activeModules: string[] = [];
@ -108,7 +109,7 @@ export class ModuleRegistry {
for (const [moduleName, moduleEntry] of this.moduleMetadata.getEntries()) {
const { licenseFlag, class: ModuleClass } = moduleEntry;
if (licenseFlag && !this.licenseState.isLicensed(licenseFlag)) {
if (licenseFlag !== undefined && !this.licenseState.isLicensed(licenseFlag)) {
this.logger.debug(`Skipped init for unlicensed module "${moduleName}"`);
continue;
}

View File

@ -9,6 +9,7 @@ export const MODULE_NAMES = [
'data-table',
'mcp',
'chat-hub',
'provisioning',
] as const;
export type ModuleName = (typeof MODULE_NAMES)[number];

View File

@ -4,7 +4,11 @@ import type { LicenseFlag, ModuleClass } from './module';
type ModuleEntry = {
class: ModuleClass;
licenseFlag?: LicenseFlag;
/*
* If singular, checks if that feature ls licensed,
* if multiple, checks that any of the features are licensed
*/
licenseFlag?: LicenseFlag | LicenseFlag[];
};
@Service()

View File

@ -84,7 +84,7 @@ export type ModuleClass = Constructable<ModuleInterface>;
export type LicenseFlag = (typeof LICENSE_FEATURES)[keyof typeof LICENSE_FEATURES];
export const BackendModule =
(opts: { name: string; licenseFlag?: LicenseFlag }): ClassDecorator =>
(opts: { name: string; licenseFlag?: LicenseFlag | LicenseFlag[] }): ClassDecorator =>
(target) => {
Container.get(ModuleMetadata).register(opts.name, {
class: target as unknown as ModuleClass,

View File

@ -120,6 +120,8 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
"insights:*",
"oidc:manage",
"oidc:*",
"provisioning:manage",
"provisioning:*",
"dataTable:create",
"dataTable:read",
"dataTable:update",

View File

@ -27,6 +27,7 @@ export const RESOURCES = {
folder: [...DEFAULT_OPERATIONS, 'move'] as const,
insights: ['list'] as const,
oidc: ['manage'] as const,
provisioning: ['manage'] as const,
dataTable: [...DEFAULT_OPERATIONS, 'readRow', 'writeRow', 'listProject'] as const,
execution: ['delete', 'read', 'retry', 'list', 'get'] as const,
workflowTags: ['update', 'list'] as const,

View File

@ -89,6 +89,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'folder:create',
'folder:list',
'oidc:manage',
'provisioning:manage',
'dataTable:list',
'role:manage',
'mcp:manage',

View File

@ -23,6 +23,7 @@ describe('permissions', () => {
orchestration: {},
project: {},
saml: {},
provisioning: {},
securityAudit: {},
sourceControl: {},
tag: {},
@ -106,6 +107,7 @@ describe('permissions', () => {
},
saml: {},
oidc: {},
provisioning: {},
mcp: {},
mcpApiKey: {},
securityAudit: {},

View File

@ -0,0 +1,7 @@
import { PROVISIONING_PREFERENCES_DB_KEY } from '../constants';
describe('constants', () => {
it('should have the correct constants', () => {
expect(PROVISIONING_PREFERENCES_DB_KEY).toBe('features.provisioning');
});
});

View File

@ -0,0 +1,52 @@
import type { LicenseState } from '@n8n/backend-common';
import { mock } from 'jest-mock-extended';
import { ProvisioningController } from '../provisioning.controller.ee';
import { type ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
import { type Response } from 'express';
import { type AuthenticatedRequest } from '@n8n/db';
import { type ProvisioningConfigDto } from '@n8n/api-types';
const provisioningService = mock<ProvisioningService>();
const licenseState = mock<LicenseState>();
const controller = new ProvisioningController(provisioningService, licenseState);
describe('ProvisioningController', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('getConfig', () => {
const req = mock<AuthenticatedRequest>();
const res = mock<Response>({
json: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
});
it('should return 403 if provisioning is not licensed', async () => {
licenseState.isProvisioningLicensed.mockReturnValue(false);
await controller.getConfig(req, res);
expect(res.status).toHaveBeenCalledWith(403);
});
it('should return the provisioning config', async () => {
const configResponse: ProvisioningConfigDto = {
scopesProvisionInstanceRole: true,
scopesProvisionProjectRoles: true,
scopesProvisioningFrequency: 'every_login',
scopesName: 'n8n_test_scope',
scopesInstanceRoleClaimName: 'n8n_test_instance_role',
scopesProjectsRolesClaimName: 'n8n_test_projects_roles',
};
licenseState.isProvisioningLicensed.mockReturnValue(true);
provisioningService.getConfig.mockResolvedValue(configResponse);
const config = await controller.getConfig(req, res);
expect(config).toEqual(configResponse);
});
});
});

View File

@ -0,0 +1,135 @@
import type { Logger } from '@n8n/backend-common';
import { mock } from 'jest-mock-extended';
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
import { type SettingsRepository } from '@n8n/db';
import { type GlobalConfig } from '@n8n/config';
import { PROVISIONING_PREFERENCES_DB_KEY } from '../constants';
import { type ProvisioningConfigDto } from '@n8n/api-types';
const globalConfig = mock<GlobalConfig>();
const settingsRepository = mock<SettingsRepository>();
const logger = mock<Logger>();
const provisioningService = new ProvisioningService(globalConfig, settingsRepository, logger);
describe('ProvisioningService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const provisioningConfigDto: ProvisioningConfigDto = {
scopesProvisionInstanceRole: true,
scopesProvisionProjectRoles: true,
scopesProvisioningFrequency: 'every_login',
scopesName: 'n8n_test_scope',
scopesInstanceRoleClaimName: 'n8n_test_instance_role',
scopesProjectsRolesClaimName: 'n8n_test_projects_roles',
};
describe('init', () => {
it('should set provisioning config from the result of loadConfig', async () => {
const originStateLoadConfig = provisioningService.loadConfig;
provisioningService.loadConfig = jest.fn().mockResolvedValue({ foo: 'bar' });
await provisioningService.init();
// @ts-expect-error - provisioningConfig is private and only accessible within the class
expect(provisioningService.provisioningConfig).toEqual({ foo: 'bar' });
provisioningService.loadConfig = originStateLoadConfig;
});
});
describe('getConfig', () => {
it('should set provisioning config from the result of loadConfig, then return it if it is not set', async () => {
const originStateLoadConfig = provisioningService.loadConfig;
// @ts-expect-error - provisioningConfig is private and only accessible within the class
provisioningService.provisioningConfig = undefined;
provisioningService.loadConfig = jest.fn().mockResolvedValue({ foo: 'bar' });
const config = await provisioningService.getConfig();
expect(config).toEqual({ foo: 'bar' });
expect(provisioningService.loadConfig).toHaveBeenCalledTimes(1);
provisioningService.loadConfig = originStateLoadConfig;
});
it('should return the provisioning config', async () => {
// @ts-expect-error - provisioningConfig is private and only accessible within the class
provisioningService.provisioningConfig = { foo: 'bar' };
const config = await provisioningService.getConfig();
expect(config).toEqual({ foo: 'bar' });
});
});
describe('loadConfigurationFromDatabase', () => {
it('should return the provisioning config from the database', async () => {
settingsRepository.findByKey.mockResolvedValue({
key: PROVISIONING_PREFERENCES_DB_KEY,
value: JSON.stringify(provisioningConfigDto),
loadOnStartup: true,
});
const config = await provisioningService.loadConfigurationFromDatabase();
expect(config).toEqual(provisioningConfigDto);
});
it('should return undefined if the provisioning config is not found in the database', async () => {
settingsRepository.findByKey.mockResolvedValue(null);
const config = await provisioningService.loadConfigurationFromDatabase();
expect(config).toBeUndefined();
});
it('should return undefined if the provisioning config is invalid', async () => {
settingsRepository.findByKey.mockResolvedValue({
key: PROVISIONING_PREFERENCES_DB_KEY,
value: 'invalid',
loadOnStartup: true,
});
const config = await provisioningService.loadConfigurationFromDatabase();
expect(config).toBeUndefined();
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
'Failed to load Provisioning configuration from database, falling back to default configuration.',
{ error: expect.any(Error) },
);
});
});
describe('loadConfig', () => {
it('should return the provisioning config from the database, overriding the environment configuration', async () => {
globalConfig.sso.provisioning = provisioningConfigDto;
const overriddenConfig = {
scopesProvisionInstanceRole: false,
scopesProvisionProjectRoles: false,
scopesProvisioningFrequency: 'never',
scopesName: 'n8n_test_scope_overridden',
scopesInstanceRoleClaimName: 'n8n_test_instance_role_overridden',
scopesProjectsRolesClaimName: 'n8n_test_projects_roles_overridden',
};
settingsRepository.findByKey.mockResolvedValue({
key: PROVISIONING_PREFERENCES_DB_KEY,
value: JSON.stringify(overriddenConfig),
loadOnStartup: true,
});
const config = await provisioningService.loadConfig();
expect(config).toEqual(overriddenConfig);
});
it('should return the environment configuration if the database configuration is not found', async () => {
globalConfig.sso.provisioning = provisioningConfigDto;
settingsRepository.findByKey.mockResolvedValue(null);
const config = await provisioningService.loadConfig();
expect(config).toEqual(provisioningConfigDto);
});
});
});

View File

@ -0,0 +1 @@
export const PROVISIONING_PREFERENCES_DB_KEY = 'features.provisioning';

View File

@ -0,0 +1,23 @@
import { AuthenticatedRequest } from '@n8n/db';
import { Get, GlobalScope, RestController } from '@n8n/decorators';
import { LicenseState } from '@n8n/backend-common';
import { ProvisioningService } from './provisioning.service.ee';
import { Response } from 'express';
@RestController('/sso/provisioning')
export class ProvisioningController {
constructor(
private readonly provisioningService: ProvisioningService,
private readonly licenseState: LicenseState,
) {}
@Get('/config')
@GlobalScope('provisioning:manage')
async getConfig(_req: AuthenticatedRequest, res: Response) {
if (!this.licenseState.isProvisioningLicensed()) {
return res.status(403).json({ message: 'Provisioning is not licensed' });
}
return await this.provisioningService.getConfig();
}
}

View File

@ -0,0 +1,9 @@
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule } from '@n8n/decorators';
@BackendModule({ name: 'provisioning', licenseFlag: ['feat:oidc', 'feat:saml', 'feat:ldap'] })
export class ProvisioningModule implements ModuleInterface {
async init() {
await import('./provisioning.controller.ee');
}
}

View File

@ -0,0 +1,65 @@
import { ProvisioningConfigDto } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { SettingsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { jsonParse } from 'n8n-workflow';
import { PROVISIONING_PREFERENCES_DB_KEY } from './constants';
@Service()
export class ProvisioningService {
private provisioningConfig: ProvisioningConfigDto;
constructor(
private readonly globalConfig: GlobalConfig,
private readonly settingsRepository: SettingsRepository,
private readonly logger: Logger,
) {}
async init() {
this.provisioningConfig = await this.loadConfig();
}
async getConfig(): Promise<ProvisioningConfigDto> {
if (!this.provisioningConfig) {
this.provisioningConfig = await this.loadConfig();
}
return this.provisioningConfig;
}
async loadConfigurationFromDatabase(): Promise<ProvisioningConfigDto | undefined> {
const configFromDB = await this.settingsRepository.findByKey(PROVISIONING_PREFERENCES_DB_KEY);
if (configFromDB) {
try {
const configValue = jsonParse<ProvisioningConfigDto>(configFromDB.value);
return ProvisioningConfigDto.parse(configValue);
} catch (error) {
this.logger.warn(
'Failed to load Provisioning configuration from database, falling back to default configuration.',
{ error },
);
}
}
return undefined;
}
async loadConfig(): Promise<ProvisioningConfigDto> {
const envProvidedConfig = ProvisioningConfigDto.parse(this.globalConfig.sso.provisioning);
const dbProvidedConfig = await this.loadConfigurationFromDatabase();
if (dbProvidedConfig) {
return {
...envProvidedConfig,
...dbProvidedConfig,
};
}
return envProvidedConfig;
}
}

View File

@ -35,6 +35,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
logStreaming: {},
saml: {},
oidc: {},
provisioning: {},
securityAudit: {},
folder: {},
insights: {},