mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-27 23:07:12 +02:00
feat: Add GET /sso/provisioning/config endpoint for sso provisioning config (#20850)
This commit is contained in:
parent
697a144338
commit
c40aaa5575
|
|
@ -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';
|
||||
|
|
|
|||
11
packages/@n8n/api-types/src/dto/provisioning/config.dto.ts
Normal file
11
packages/@n8n/api-types/src/dto/provisioning/config.dto.ts
Normal 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(),
|
||||
}) {}
|
||||
|
|
@ -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
|
||||
// --------------------
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const MODULE_NAMES = [
|
|||
'data-table',
|
||||
'mcp',
|
||||
'chat-hub',
|
||||
'provisioning',
|
||||
] as const;
|
||||
|
||||
export type ModuleName = (typeof MODULE_NAMES)[number];
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
|||
'folder:create',
|
||||
'folder:list',
|
||||
'oidc:manage',
|
||||
'provisioning:manage',
|
||||
'dataTable:list',
|
||||
'role:manage',
|
||||
'mcp:manage',
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1
packages/cli/src/modules/provisioning.ee/constants.ts
Normal file
1
packages/cli/src/modules/provisioning.ee/constants.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const PROVISIONING_PREFERENCES_DB_KEY = 'features.provisioning';
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
|
|||
logStreaming: {},
|
||||
saml: {},
|
||||
oidc: {},
|
||||
provisioning: {},
|
||||
securityAudit: {},
|
||||
folder: {},
|
||||
insights: {},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user