feat(core): Add feature flag for custom instance roles

This commit is contained in:
Yuliia Pominchuk
2026-06-18 14:35:05 +02:00
parent 79560920cb
commit 35ce3671a5
10 changed files with 114 additions and 0 deletions
@@ -213,6 +213,9 @@ export interface FrontendSettings {
folders: {
enabled: boolean;
};
customInstanceRoles: {
enabled: boolean;
};
banners: {
dismissed: string[];
};
@@ -0,0 +1,28 @@
import { Container } from '@n8n/di';
import { GlobalConfig } from '../../index';
describe('RolesConfig', () => {
beforeEach(() => {
Container.reset();
vi.unstubAllEnvs();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should default the custom instance roles flag to off', () => {
const { roles } = Container.get(GlobalConfig);
expect(roles.customInstanceRolesEnabled).toBe(false);
});
it('should enable the custom instance roles flag from env', () => {
vi.stubEnv('N8N_CUSTOM_INSTANCE_ROLES_ENABLED', 'true');
const { roles } = Container.get(GlobalConfig);
expect(roles.customInstanceRolesEnabled).toBe(true);
});
});
@@ -0,0 +1,11 @@
import { Config, Env } from '../decorators';
@Config
export class RolesConfig {
/**
* Dark-launch flag for the custom instance roles UI. When off, the
* instance-roles surface is not exposed in the frontend.
*/
@Env('N8N_CUSTOM_INSTANCE_ROLES_ENABLED')
customInstanceRolesEnabled: boolean = false;
}
+5
View File
@@ -34,6 +34,7 @@ import { NodesConfig } from './configs/nodes.config';
import { PersonalizationConfig } from './configs/personalization.config';
import { PublicApiConfig } from './configs/public-api.config';
import { RedisConfig } from './configs/redis.config';
import { RolesConfig } from './configs/roles.config';
import { TaskRunnersConfig } from './configs/runners.config';
import { ScalingModeConfig } from './configs/scaling-mode.config';
import { SecurityConfig } from './configs/security.config';
@@ -84,6 +85,7 @@ export { PasswordConfig } from './configs/password.config';
export { AgentsConfig } from './configs/agents.config';
export { CompressionNodeConfig } from './configs/compression.config';
export { RedisConfig } from './configs/redis.config';
export { RolesConfig } from './configs/roles.config';
export { EndpointsConfig, PrometheusMetricsConfig };
const protocolSchema = z.enum(['http', 'https']);
@@ -284,4 +286,7 @@ export class GlobalConfig {
@Nested
instanceSettingsLoader: InstanceSettingsLoaderConfig;
@Nested
roles: RolesConfig;
}
+3
View File
@@ -597,6 +597,9 @@ describe('GlobalConfig', () => {
daytonaApiUrl: '',
daytonaApiKey: '',
},
roles: {
customInstanceRolesEnabled: false,
},
} satisfies GlobalConfigShape;
it('should use all default values when no env variables are defined', () => {
@@ -35,6 +35,7 @@ describe('FrontendService', () => {
tags: { disabled: false },
logging: { level: 'info' },
hiringBanner: { enabled: false },
roles: { customInstanceRolesEnabled: false },
versionNotifications: {
enabled: false,
endpoint: '',
@@ -399,6 +399,9 @@ export class FrontendService {
folders: {
enabled: false,
},
customInstanceRoles: {
enabled: this.globalConfig.roles.customInstanceRolesEnabled,
},
evaluation: {
quota: this.licenseState.getMaxWorkflowsWithEvaluations(),
},
@@ -176,6 +176,9 @@ export const defaultSettings: FrontendSettings = {
folders: {
enabled: false,
},
customInstanceRoles: {
enabled: false,
},
evaluation: {
quota: 0,
},
@@ -312,4 +312,56 @@ describe('settings.store', () => {
expect(settingsStore.isOtelCustomSpanAttributesEnabled).toBe(true);
});
});
describe('isCustomInstanceRolesEnabled', () => {
it('should return true when the dark-launch flag is on', async () => {
getSettings.mockResolvedValueOnce({
...mockSettings,
customInstanceRoles: { enabled: true },
});
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
expect(settingsStore.isCustomInstanceRolesEnabled).toBe(true);
});
it('should return false when the dark-launch flag is off', async () => {
getSettings.mockResolvedValueOnce({
...mockSettings,
customInstanceRoles: { enabled: false },
});
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
expect(settingsStore.isCustomInstanceRolesEnabled).toBe(false);
});
it('should return false when the setting is absent', async () => {
getSettings.mockResolvedValueOnce({
...mockSettings,
customInstanceRoles: undefined,
});
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
expect(settingsStore.isCustomInstanceRolesEnabled).toBe(false);
});
it('should stay off when the customRoles license is on but the flag is off (gates are independent)', async () => {
getSettings.mockResolvedValueOnce({
...mockSettings,
enterprise: { customRoles: true },
customInstanceRoles: { enabled: false },
});
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
expect(settingsStore.isCustomRolesFeatureEnabled).toBe(true);
expect(settingsStore.isCustomInstanceRolesEnabled).toBe(false);
});
});
});
@@ -201,6 +201,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
() => settings.value.enterprise?.customRoles ?? false,
);
const isCustomInstanceRolesEnabled = computed(
() => settings.value.customInstanceRoles?.enabled ?? false,
);
const areTagsEnabled = computed(() =>
settings.value.workflowTagsDisabled !== undefined ? !settings.value.workflowTagsDisabled : true,
);
@@ -440,6 +444,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isFoldersFeatureEnabled,
isAiAssistantEnabled,
isCustomRolesFeatureEnabled,
isCustomInstanceRolesEnabled,
areTagsEnabled,
isAutosaveEnabled,
isHiringBannerEnabled,