Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/@n8n/api-types/src/frontend-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,16 @@ export type FrontendModuleSettings = {
'quick-connect'?: {
options: QuickConnectOption[];
};

/**
* Client settings for external secrets module.
*/
'external-secrets'?: {
/** Whether multiple connections per vault type are enabled. */
multipleConnections: boolean;
/** Whether project-scoped external secrets are enabled. */
forProjects: boolean;
};
};

export type N8nEnvFeatFlagValue = boolean | string | number | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ export class ExternalSecretsModule implements ModuleInterface {
externalSecretsProxy.setManager(externalSecretsManager);
}

async settings() {
const { ExternalSecretsConfig } = await import('./external-secrets.config');
const config = Container.get(ExternalSecretsConfig);

return {
multipleConnections: config.externalSecretsMultipleConnections,
forProjects: config.externalSecretsForProjects,
};
}

@OnShutdown()
async shutdown() {
const { ExternalSecretsManager } = await import('./external-secrets-manager.ee');
Expand Down
9 changes: 3 additions & 6 deletions packages/frontend/editor-ui/src/app/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { MfaRequiredError } from '@n8n/rest-api-client';
import { useRecentResources } from '@/features/shared/commandBar/composables/useRecentResources';
import { usePostHog } from '@/app/stores/posthog.store';
import { TEMPLATE_SETUP_EXPERIENCE } from '@/app/constants/experiments';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';

const ChangePasswordView = async () =>
Expand Down Expand Up @@ -76,12 +75,10 @@ const SamlOnboarding = async () => await import('@/features/settings/sso/views/S
const SettingsSourceControl = async () =>
await import('@/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue');
const SettingsExternalSecrets = async () => {
const { check } = useEnvFeatureFlag();
const settingsStore = useSettingsStore();
const moduleConfig = settingsStore.moduleSettings['external-secrets'];

if (
check.value('EXTERNAL_SECRETS_FOR_PROJECTS') ||
check.value('EXTERNAL_SECRETS_MULTIPLE_CONNECTIONS')
) {
if (moduleConfig?.multipleConnections || moduleConfig?.forProjects) {
return await import(
'@/features/integrations/secretsProviders.ee/views/SettingsSecretsProviders.ee.vue'
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,13 @@ vi.mock('@/app/composables/useToast', () => ({
})),
}));

vi.mock('@/features/shared/envFeatureFlag/useEnvFeatureFlag', () => ({
useEnvFeatureFlag: vi.fn(() => ({
check: {
value: vi.fn((flag: string) => flag === 'EXTERNAL_SECRETS_FOR_PROJECTS'),
vi.mock('@/app/stores/settings.store', () => ({
useSettingsStore: vi.fn(() => ({
moduleSettings: {
'external-secrets': {
multipleConnections: true,
forProjects: true,
},
},
})),
}));
Expand Down Expand Up @@ -444,14 +447,15 @@ describe('ProjectExternalSecrets', () => {
});

it('should not fetch data when feature is disabled', async () => {
const { useEnvFeatureFlag } = await import(
'@/features/shared/envFeatureFlag/useEnvFeatureFlag'
);
vi.mocked(useEnvFeatureFlag).mockReturnValue({
check: {
value: vi.fn(() => false),
const { useSettingsStore } = await import('@/app/stores/settings.store');
vi.mocked(useSettingsStore).mockReturnValue({
moduleSettings: {
'external-secrets': {
multipleConnections: true,
forProjects: false,
},
},
} as unknown as ReturnType<typeof useEnvFeatureFlag>);
} as unknown as ReturnType<typeof useSettingsStore>);

const fetchSpy = vi.spyOn(projectsStore, 'getProjectSecretProviders');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useSecretsProvidersList } from '@/features/integrations/secretsProvider
import type { SecretProviderConnection } from '@n8n/api-types';
import { useUIStore } from '@/app/stores/ui.store';
import { SECRETS_PROVIDER_CONNECTION_MODAL_KEY, VIEWS } from '@/app/constants';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useRouter } from 'vue-router';

import {
Expand All @@ -30,9 +30,9 @@ const router = useRouter();
const projectsStore = useProjectsStore();
const uiStore = useUIStore();
const rbacStore = useRBACStore();
const settingsStore = useSettingsStore();
const secretsProviders = useSecretsProvidersList();
const secretsProviderConnection = useSecretsProviderConnection();
const envFeatureFlag = useEnvFeatureFlag();

interface ConnectionRow {
id: string;
Expand All @@ -50,8 +50,8 @@ const expandedConnections = ref<Set<string>>(new Set());
const currentPage = ref(0);
const itemsPerPage = ref(5);

const isFeatureEnabled = computed(() =>
envFeatureFlag.check.value('EXTERNAL_SECRETS_FOR_PROJECTS'),
const isFeatureEnabled = computed(
() => settingsStore.moduleSettings['external-secrets']?.forProjects ?? false,
Copy link
Contributor

@konstantintieber konstantintieber Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know how I would change these moduleSettings on a cloud instance once I ssh into it?
Asking since I know how to do this with an env var but not with moduleSettings.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah nevermind, I see you're wiring up the env vars with module settings in packages/cli/src/modules/external-secrets.ee/external-secrets.module.ts -> settings()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Module settings are run time logic based so you can't directly affect their values.

In this case the value is based on our backend logic which is

  • if no env var -> return true
  • if there is an env var -> use the env var value

So you could still set the env var to inderectly affect the module setting value

);

// Permissions
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { setActivePinia, createPinia } from 'pinia';
import { computed } from 'vue';
import { vi } from 'vitest';
import { useExternalSecretsStore } from './externalSecrets.ee.store';
import type { ExternalSecretsProvider } from '@n8n/api-types';
Expand Down Expand Up @@ -28,10 +27,8 @@ const {
updateProvider: vi.fn(),
}));

// Hoisted mock for feature flag composable
const { useEnvFeatureFlag } = vi.hoisted(() => ({
useEnvFeatureFlag: vi.fn(),
}));
// Mock module settings - mutable so tests can change it
const mockModuleSettings: Record<string, unknown> = {};

// Mock API client module
vi.mock('@n8n/rest-api-client', () => ({
Expand Down Expand Up @@ -68,14 +65,10 @@ vi.mock('@/app/stores/settings.store', () => ({
isEnterpriseFeatureEnabled: {
[EnterpriseEditionFeature.ExternalSecrets]: true,
},
moduleSettings: mockModuleSettings,
})),
}));

// Mock feature flag composable
vi.mock('@/features/shared/envFeatureFlag/useEnvFeatureFlag', () => ({
useEnvFeatureFlag,
}));

// Test fixtures
const createMockProvider = (
overrides?: Partial<ExternalSecretsProvider>,
Expand Down Expand Up @@ -150,10 +143,12 @@ const expectedProjectSecretsObject = {
};

// Helper functions
const mockFeatureFlag = (featureName: string, enabled: boolean) => {
vi.mocked(useEnvFeatureFlag).mockReturnValue({
check: computed(() => (flag: string) => flag === featureName && enabled),
} as ReturnType<typeof useEnvFeatureFlag>);
const setModuleSettings = (settings: { forProjects?: boolean; multipleConnections?: boolean }) => {
mockModuleSettings['external-secrets'] = settings;
};

const clearModuleSettings = () => {
delete mockModuleSettings['external-secrets'];
};

const setHasPermission = (hasPermission: boolean) => {
Expand All @@ -165,7 +160,7 @@ describe('externalSecretsStore', () => {
setActivePinia(createPinia());
vi.clearAllMocks();
// Reset to defaults
mockFeatureFlag('EXTERNAL_SECRETS_FOR_PROJECTS', false);
clearModuleSettings();
setHasPermission(true);
});

Expand Down Expand Up @@ -209,8 +204,8 @@ describe('externalSecretsStore', () => {
});

describe('secretsAsObject', () => {
it('should only contain the global secrets if development feature flag for project secrets is disabled', () => {
mockFeatureFlag('EXTERNAL_SECRETS_FOR_PROJECTS', false);
it('should only contain the global secrets if forProjects is disabled', () => {
clearModuleSettings();
const store = useExternalSecretsStore();
store.state.secrets = mockGlobalSecrets;
store.state.projectSecrets = mockProjectSecrets;
Expand All @@ -221,7 +216,7 @@ describe('externalSecretsStore', () => {
});

it('should contain combined global and project secrets', () => {
mockFeatureFlag('EXTERNAL_SECRETS_FOR_PROJECTS', true);
setModuleSettings({ forProjects: true });
const store = useExternalSecretsStore();
store.state.secrets = mockGlobalSecrets;
store.state.projectSecrets = mockProjectSecrets;
Expand Down Expand Up @@ -251,8 +246,8 @@ describe('externalSecretsStore', () => {
});

describe('fetchGlobalSecrets()', () => {
it('should fetch and set global secrets when user has permission (feature flag disabled)', async () => {
mockFeatureFlag('EXTERNAL_SECRETS_FOR_PROJECTS', false);
it('should fetch and set global secrets when user has permission (no module settings)', async () => {
clearModuleSettings();
setHasPermission(true);
getExternalSecrets.mockResolvedValue(mockGlobalSecrets);
const store = useExternalSecretsStore();
Expand All @@ -265,8 +260,8 @@ describe('externalSecretsStore', () => {
expect(store.state.secrets).toEqual(mockGlobalSecrets);
});

it('should use new completions endpoint when feature flag is enabled', async () => {
mockFeatureFlag('EXTERNAL_SECRETS_FOR_PROJECTS', true);
it('should use new completions endpoint when forProjects is enabled', async () => {
setModuleSettings({ forProjects: true });
setHasPermission(true);
getGlobalExternalSecrets.mockResolvedValue(mockGlobalSecrets);
const store = useExternalSecretsStore();
Expand All @@ -291,8 +286,8 @@ describe('externalSecretsStore', () => {
expect(store.state.secrets).toEqual({});
});

it('should set secrets to empty object on API error (feature flag disabled)', async () => {
mockFeatureFlag('EXTERNAL_SECRETS_FOR_PROJECTS', false);
it('should set secrets to empty object on API error (no module settings)', async () => {
clearModuleSettings();
setHasPermission(true);
getExternalSecrets.mockRejectedValue(new Error('API Error'));
const store = useExternalSecretsStore();
Expand All @@ -302,8 +297,8 @@ describe('externalSecretsStore', () => {
expect(store.state.secrets).toEqual({});
});

it('should set secrets to empty object on API error (feature flag enabled)', async () => {
mockFeatureFlag('EXTERNAL_SECRETS_FOR_PROJECTS', true);
it('should set secrets to empty object on API error (forProjects enabled)', async () => {
setModuleSettings({ forProjects: true });
setHasPermission(true);
getGlobalExternalSecrets.mockRejectedValue(new Error('API Error'));
const store = useExternalSecretsStore();
Expand All @@ -315,8 +310,8 @@ describe('externalSecretsStore', () => {
});

describe('fetchProjectSecrets()', () => {
it('should leave state.projectSecrets as empty object if development feature flag for project secrets is disabled', async () => {
mockFeatureFlag('EXTERNAL_SECRETS_FOR_PROJECTS', false);
it('should leave state.projectSecrets as empty object if forProjects is disabled', async () => {
clearModuleSettings();
setHasPermission(true);
getProjectExternalSecrets.mockResolvedValue(mockProjectSecrets);
const store = useExternalSecretsStore();
Expand All @@ -328,7 +323,7 @@ describe('externalSecretsStore', () => {
});

it('should set state.projectSecrets to response from API', async () => {
mockFeatureFlag('EXTERNAL_SECRETS_FOR_PROJECTS', true);
setModuleSettings({ forProjects: true });
setHasPermission(true);
getProjectExternalSecrets.mockResolvedValue(mockProjectSecrets);
const store = useExternalSecretsStore();
Expand All @@ -339,8 +334,8 @@ describe('externalSecretsStore', () => {
expect(store.state.projectSecrets).toEqual(mockProjectSecrets);
});

it('should not fetch when feature flag is enabled but user lacks permission', async () => {
mockFeatureFlag('EXTERNAL_SECRETS_FOR_PROJECTS', true);
it('should not fetch when forProjects is enabled but user lacks permission', async () => {
setModuleSettings({ forProjects: true });
setHasPermission(false);
const store = useExternalSecretsStore();

Expand All @@ -351,7 +346,7 @@ describe('externalSecretsStore', () => {
});

it('should set projectSecrets to empty object on API error', async () => {
mockFeatureFlag('EXTERNAL_SECRETS_FOR_PROJECTS', true);
setModuleSettings({ forProjects: true });
setHasPermission(true);
getProjectExternalSecrets.mockRejectedValue(new Error('API Error'));
const store = useExternalSecretsStore();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import * as externalSecretsApi from '@n8n/rest-api-client';
import { connectProvider } from '@n8n/rest-api-client';
import { useRBACStore } from '@/app/stores/rbac.store';
import type { ExternalSecretsProvider } from '@n8n/api-types';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';

/**
* Transforms flat dot-notated secret keys into a nested object structure.
Expand Down Expand Up @@ -62,7 +61,10 @@ export const useExternalSecretsStore = defineStore('externalSecrets', () => {
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const rbacStore = useRBACStore();
const { check: checkDevFeatureFlag } = useEnvFeatureFlag();

const externalSecretsModuleSettings = computed(
() => settingsStore.moduleSettings['external-secrets'],
);

const state = reactive({
providers: [] as ExternalSecretsProvider[],
Expand All @@ -86,7 +88,7 @@ export const useExternalSecretsStore = defineStore('externalSecrets', () => {
);
const globalSecretsAsObject = computed(() => transformSecretsToNestedObject(secrets.value));
const secretsAsObject = computed(() => {
if (checkDevFeatureFlag.value('EXTERNAL_SECRETS_FOR_PROJECTS')) {
if (externalSecretsModuleSettings.value?.forProjects) {
/**
* This combines secrets from both global and project scopes.
* Note: The backend enforces that provider names are unique across scopes, preventing conflicts.
Expand All @@ -102,9 +104,8 @@ export const useExternalSecretsStore = defineStore('externalSecrets', () => {
async function fetchGlobalSecrets() {
if (rbacStore.hasScope('externalSecret:list')) {
try {
const betaFeatureEnabled =
checkDevFeatureFlag.value('EXTERNAL_SECRETS_FOR_PROJECTS') ||
checkDevFeatureFlag.value('EXTERNAL_SECRETS_MULTIPLE_CONNECTIONS');
const moduleConfig = externalSecretsModuleSettings.value;
const betaFeatureEnabled = moduleConfig?.forProjects || moduleConfig?.multipleConnections;
state.secrets = betaFeatureEnabled
? await externalSecretsApi.getGlobalExternalSecrets(rootStore.restApiContext)
: await externalSecretsApi.getExternalSecrets(rootStore.restApiContext);
Expand All @@ -115,7 +116,7 @@ export const useExternalSecretsStore = defineStore('externalSecrets', () => {
}

async function fetchProjectSecrets(projectId: string) {
if (!checkDevFeatureFlag.value('EXTERNAL_SECRETS_FOR_PROJECTS')) {
if (!externalSecretsModuleSettings.value?.forProjects) {
// project-scoped secrets are still under development. Only available behind feature flag
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ import ProjectIcon from '@/features/collaboration/projects/components/ProjectIco
import { splitName } from '@/features/collaboration/projects/projects.utils';
import type { ProjectListItem } from '@/features/collaboration/projects/projects.types';
import { isIconOrEmoji, type IconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { useSettingsStore } from '@/app/stores/settings.store';

const i18n = useI18n();
const rbacStore = useRBACStore();
const { check: checkDevFeatureFlag } = useEnvFeatureFlag();
const isProjectScopedSecretsEnabled = checkDevFeatureFlag.value('EXTERNAL_SECRETS_FOR_PROJECTS');
const settingsStore = useSettingsStore();
const isProjectScopedSecretsEnabled =
settingsStore.moduleSettings['external-secrets']?.forProjects ?? false;

const props = defineProps<{
provider: SecretProviderConnection;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,6 @@ vi.mock('@/features/collaboration/projects/projects.store', () => ({
useProjectsStore: vi.fn(() => mockProjectsStore),
}));

vi.mock('@/features/shared/envFeatureFlag/useEnvFeatureFlag', () => ({
useEnvFeatureFlag: vi.fn(() => ({
check: {
value: vi.fn((flag: string) => flag === 'EXTERNAL_SECRETS_FOR_PROJECTS'),
},
})),
}));

const initialState = {
[STORES.UI]: {
modalsById: {
Expand All @@ -142,6 +134,14 @@ const initialState = {
},
modalStack: [SECRETS_PROVIDER_CONNECTION_MODAL_KEY],
},
[STORES.SETTINGS]: {
moduleSettings: {
'external-secrets': {
multipleConnections: true,
forProjects: true,
},
},
},
};

const renderComponent = createComponentRenderer(SecretsProviderConnectionModal, {
Expand Down
Loading
Loading