mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
fix(core): Write full SSO provisioning config from env-managed loader (#30885)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e2c2a5a62c
commit
3103d38798
|
|
@ -1,7 +1,9 @@
|
|||
import { ProvisioningConfigDto } from '@n8n/api-types';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { InstanceSettingsLoaderConfig } from '@n8n/config';
|
||||
import type { GlobalConfig, InstanceSettingsLoaderConfig } from '@n8n/config';
|
||||
import type { SettingsRepository } from '@n8n/db';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
|
||||
import { ProvisioningInstanceSettingsLoader } from '../../loaders/sso/provisioning.instance-settings-loader';
|
||||
|
||||
|
|
@ -9,12 +11,22 @@ describe('ProvisioningInstanceSettingsLoader', () => {
|
|||
const logger = mock<Logger>({ scoped: jest.fn().mockReturnThis() });
|
||||
const settingsRepository = mock<SettingsRepository>();
|
||||
|
||||
const globalConfig = {
|
||||
sso: {
|
||||
provisioning: {
|
||||
scopesName: 'n8n',
|
||||
scopesInstanceRoleClaimName: 'n8n_instance_role',
|
||||
scopesProjectsRolesClaimName: 'n8n_projects',
|
||||
},
|
||||
},
|
||||
} as GlobalConfig;
|
||||
|
||||
const createLoader = (configOverrides: Partial<InstanceSettingsLoaderConfig> = {}) => {
|
||||
const config = {
|
||||
ssoUserRoleProvisioning: 'disabled',
|
||||
...configOverrides,
|
||||
} as InstanceSettingsLoaderConfig;
|
||||
return new ProvisioningInstanceSettingsLoader(config, settingsRepository, logger);
|
||||
return new ProvisioningInstanceSettingsLoader(config, globalConfig, settingsRepository, logger);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -40,6 +52,9 @@ describe('ProvisioningInstanceSettingsLoader', () => {
|
|||
scopesProvisionInstanceRole: false,
|
||||
scopesProvisionProjectRoles: false,
|
||||
scopesUseExpressionMapping: false,
|
||||
scopesName: 'n8n',
|
||||
scopesInstanceRoleClaimName: 'n8n_instance_role',
|
||||
scopesProjectsRolesClaimName: 'n8n_projects',
|
||||
}),
|
||||
loadOnStartup: true,
|
||||
},
|
||||
|
|
@ -59,6 +74,9 @@ describe('ProvisioningInstanceSettingsLoader', () => {
|
|||
scopesProvisionInstanceRole: true,
|
||||
scopesProvisionProjectRoles: false,
|
||||
scopesUseExpressionMapping: false,
|
||||
scopesName: 'n8n',
|
||||
scopesInstanceRoleClaimName: 'n8n_instance_role',
|
||||
scopesProjectsRolesClaimName: 'n8n_projects',
|
||||
}),
|
||||
loadOnStartup: true,
|
||||
},
|
||||
|
|
@ -78,10 +96,31 @@ describe('ProvisioningInstanceSettingsLoader', () => {
|
|||
scopesProvisionInstanceRole: true,
|
||||
scopesProvisionProjectRoles: true,
|
||||
scopesUseExpressionMapping: false,
|
||||
scopesName: 'n8n',
|
||||
scopesInstanceRoleClaimName: 'n8n_instance_role',
|
||||
scopesProjectsRolesClaimName: 'n8n_projects',
|
||||
}),
|
||||
loadOnStartup: true,
|
||||
},
|
||||
{ conflictPaths: ['key'] },
|
||||
);
|
||||
});
|
||||
|
||||
describe('persisted value is consumable by ProvisioningConfigDto', () => {
|
||||
// The loader writes the row that the provisioning service reads back via
|
||||
// ProvisioningConfigDto.parse(). If parse fails the service silently falls
|
||||
// back to disabled defaults and IdP role claims are ignored.
|
||||
const modes = ['disabled', 'instance_role', 'instance_and_project_roles'] as const;
|
||||
|
||||
it.each(modes)('round-trips for %s', async (mode) => {
|
||||
const loader = createLoader({ ssoUserRoleProvisioning: mode });
|
||||
|
||||
await loader.apply();
|
||||
|
||||
const upsertCall = settingsRepository.upsert.mock.calls[0][0] as { value: string };
|
||||
const persisted = jsonParse<unknown>(upsertCall.value);
|
||||
|
||||
expect(() => ProvisioningConfigDto.parse(persisted)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { type ProvisioningMode, type ProvisioningModeFlags } from '@n8n/api-types';
|
||||
import { ProvisioningConfigDto, type ProvisioningMode } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { InstanceSettingsLoaderConfig } from '@n8n/config';
|
||||
import { GlobalConfig, InstanceSettingsLoaderConfig } from '@n8n/config';
|
||||
import { SettingsRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { z } from 'zod';
|
||||
|
|
@ -15,28 +15,19 @@ const ENV_PROVISIONING_MODES = [
|
|||
'instance_and_project_roles',
|
||||
] as const satisfies readonly ProvisioningMode[];
|
||||
|
||||
const provisioningSchema = z
|
||||
.object({
|
||||
ssoUserRoleProvisioning: z.enum(ENV_PROVISIONING_MODES, {
|
||||
errorMap: () => ({
|
||||
message: `N8N_SSO_USER_ROLE_PROVISIONING must be one of: ${ENV_PROVISIONING_MODES.join(', ')}`,
|
||||
}),
|
||||
const modeSchema = z.object({
|
||||
ssoUserRoleProvisioning: z.enum(ENV_PROVISIONING_MODES, {
|
||||
errorMap: () => ({
|
||||
message: `N8N_SSO_USER_ROLE_PROVISIONING must be one of: ${ENV_PROVISIONING_MODES.join(', ')}`,
|
||||
}),
|
||||
})
|
||||
.transform(
|
||||
(input): ProvisioningModeFlags => ({
|
||||
scopesProvisionInstanceRole:
|
||||
input.ssoUserRoleProvisioning === 'instance_role' ||
|
||||
input.ssoUserRoleProvisioning === 'instance_and_project_roles',
|
||||
scopesProvisionProjectRoles: input.ssoUserRoleProvisioning === 'instance_and_project_roles',
|
||||
scopesUseExpressionMapping: false,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@Service()
|
||||
export class ProvisioningInstanceSettingsLoader {
|
||||
constructor(
|
||||
private readonly config: InstanceSettingsLoaderConfig,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly settingsRepository: SettingsRepository,
|
||||
private logger: Logger,
|
||||
) {
|
||||
|
|
@ -44,15 +35,30 @@ export class ProvisioningInstanceSettingsLoader {
|
|||
}
|
||||
|
||||
async apply(): Promise<void> {
|
||||
const parsed = provisioningSchema.safeParse(this.config);
|
||||
const parsed = modeSchema.safeParse(this.config);
|
||||
if (!parsed.success) {
|
||||
throw new InstanceBootstrappingError(parsed.error.issues[0].message);
|
||||
}
|
||||
|
||||
const mode = parsed.data.ssoUserRoleProvisioning;
|
||||
const { provisioning } = this.globalConfig.sso;
|
||||
|
||||
// Persist the full ProvisioningConfigDto shape. The read path rejects
|
||||
// partial rows and silently falls back to disabled defaults.
|
||||
const value: ProvisioningConfigDto = {
|
||||
scopesProvisionInstanceRole:
|
||||
mode === 'instance_role' || mode === 'instance_and_project_roles',
|
||||
scopesProvisionProjectRoles: mode === 'instance_and_project_roles',
|
||||
scopesUseExpressionMapping: false,
|
||||
scopesName: provisioning.scopesName,
|
||||
scopesInstanceRoleClaimName: provisioning.scopesInstanceRoleClaimName,
|
||||
scopesProjectsRolesClaimName: provisioning.scopesProjectsRolesClaimName,
|
||||
};
|
||||
|
||||
await this.settingsRepository.upsert(
|
||||
{
|
||||
key: PROVISIONING_PREFERENCES_DB_KEY,
|
||||
value: JSON.stringify(parsed.data),
|
||||
value: JSON.stringify(value),
|
||||
loadOnStartup: true,
|
||||
},
|
||||
{ conflictPaths: ['key'] },
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user