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:
Thanasis G 2026-05-22 18:11:45 +03:00 committed by GitHub
parent e2c2a5a62c
commit 3103d38798
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 67 additions and 22 deletions

View File

@ -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();
});
});
});

View File

@ -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'] },