Skip to content

Commit fd395cf

Browse files
chore(core): Add dev feature flag for multiple global secret connections (no-changelog) (#25844)
1 parent c2a824a commit fd395cf

11 files changed

Lines changed: 306 additions & 23 deletions
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import type { NextFunction, Request, Response } from 'express';
2+
import type { Logger } from '@n8n/backend-common';
3+
import { mock } from 'jest-mock-extended';
4+
5+
import type { SecretsProvidersConnectionsService } from '../secrets-providers-connections.service.ee';
6+
import { SecretProvidersConnectionsController } from '../secrets-providers-connections.controller.ee';
7+
import { ExternalSecretsConfig } from '../external-secrets.config';
8+
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
9+
import * as responseHelper from '@/response-helper';
10+
11+
jest.mock('@/response-helper', () => ({
12+
sendErrorResponse: jest.fn(),
13+
}));
14+
15+
describe('SecretProvidersConnectionsController', () => {
16+
let controller: SecretProvidersConnectionsController;
17+
let config: ExternalSecretsConfig;
18+
let logger: Logger;
19+
let connectionsService: SecretsProvidersConnectionsService;
20+
21+
const createMockRequest = (overrides?: Partial<Request>): Request =>
22+
({
23+
method: 'GET',
24+
body: {},
25+
...overrides,
26+
}) as Request;
27+
28+
const createMockResponse = (): Response => {
29+
const res = mock<Response>();
30+
(res.status as any) = jest.fn().mockReturnThis();
31+
(res.json as any) = jest.fn().mockReturnThis();
32+
(res.send as any) = jest.fn().mockReturnThis();
33+
return res;
34+
};
35+
36+
const createMockNextFunction = (): NextFunction => jest.fn() as NextFunction;
37+
38+
beforeEach(() => {
39+
jest.clearAllMocks();
40+
41+
config = new ExternalSecretsConfig();
42+
logger = mock<Logger>();
43+
logger.scoped = jest.fn().mockReturnValue(logger);
44+
logger.warn = jest.fn();
45+
46+
connectionsService = mock<SecretsProvidersConnectionsService>();
47+
48+
controller = new SecretProvidersConnectionsController(config, logger, connectionsService);
49+
});
50+
51+
describe('checkFeatureFlag middleware', () => {
52+
describe('when both feature flags are disabled', () => {
53+
beforeEach(() => {
54+
config.externalSecretsForProjects = false;
55+
config.externalSecretsMultipleConnections = false;
56+
});
57+
58+
it('should block any request', () => {
59+
const req = createMockRequest({ method: 'GET' });
60+
const res = createMockResponse();
61+
const next = createMockNextFunction();
62+
63+
controller.checkFeatureFlag(req, res, next);
64+
65+
expect(responseHelper.sendErrorResponse).toHaveBeenCalledWith(
66+
res,
67+
expect.any(ForbiddenError),
68+
);
69+
expect(next).not.toHaveBeenCalled();
70+
});
71+
});
72+
73+
describe('when only externalSecretsForProjects is enabled', () => {
74+
beforeEach(() => {
75+
config.externalSecretsForProjects = true;
76+
config.externalSecretsMultipleConnections = false;
77+
});
78+
79+
it('should allow all requests', () => {
80+
const req = createMockRequest({ method: 'GET' });
81+
const res = createMockResponse();
82+
const next = createMockNextFunction();
83+
84+
controller.checkFeatureFlag(req, res, next);
85+
86+
expect(logger.warn).not.toHaveBeenCalled();
87+
expect(responseHelper.sendErrorResponse).not.toHaveBeenCalled();
88+
expect(next).toHaveBeenCalled();
89+
});
90+
91+
it('should allow POST requests with projectIds', () => {
92+
const req = createMockRequest({
93+
method: 'POST',
94+
body: { projectIds: ['project-1'] },
95+
});
96+
const res = createMockResponse();
97+
const next = createMockNextFunction();
98+
99+
controller.checkFeatureFlag(req, res, next);
100+
101+
expect(logger.warn).not.toHaveBeenCalled();
102+
expect(responseHelper.sendErrorResponse).not.toHaveBeenCalled();
103+
expect(next).toHaveBeenCalled();
104+
});
105+
});
106+
107+
describe('when only externalSecretsMultipleConnections is enabled', () => {
108+
beforeEach(() => {
109+
config.externalSecretsForProjects = false;
110+
config.externalSecretsMultipleConnections = true;
111+
});
112+
113+
it('should allow POST requests without projectIds', () => {
114+
const req = createMockRequest({ method: 'POST', body: { projectIds: [] } });
115+
const res = createMockResponse();
116+
const next = createMockNextFunction();
117+
118+
controller.checkFeatureFlag(req, res, next);
119+
120+
expect(logger.warn).not.toHaveBeenCalled();
121+
expect(responseHelper.sendErrorResponse).not.toHaveBeenCalled();
122+
expect(next).toHaveBeenCalled();
123+
});
124+
125+
it('should block POST requests with projectIds (requires externalSecretsForProjects)', () => {
126+
const req = createMockRequest({
127+
method: 'POST',
128+
body: { projectIds: ['project-1'] },
129+
});
130+
const res = createMockResponse();
131+
const next = createMockNextFunction();
132+
133+
controller.checkFeatureFlag(req, res, next);
134+
135+
expect(logger.warn).toHaveBeenCalledWith(
136+
'Tried to create a project-scoped external secret connection without feature flag enabled',
137+
);
138+
expect(responseHelper.sendErrorResponse).toHaveBeenCalledWith(
139+
res,
140+
expect.any(ForbiddenError),
141+
);
142+
expect(next).not.toHaveBeenCalled();
143+
});
144+
145+
it('should block PATCH requests with projectIds (requires externalSecretsForProjects)', () => {
146+
const req = createMockRequest({
147+
method: 'PATCH',
148+
body: { projectIds: ['project-1'] },
149+
});
150+
const res = createMockResponse();
151+
const next = createMockNextFunction();
152+
153+
controller.checkFeatureFlag(req, res, next);
154+
155+
expect(logger.warn).toHaveBeenCalledWith(
156+
'Tried to create a project-scoped external secret connection without feature flag enabled',
157+
);
158+
expect(responseHelper.sendErrorResponse).toHaveBeenCalledWith(
159+
res,
160+
expect.any(ForbiddenError),
161+
);
162+
expect(next).not.toHaveBeenCalled();
163+
});
164+
});
165+
166+
describe('when both feature flags are enabled', () => {
167+
beforeEach(() => {
168+
config.externalSecretsForProjects = true;
169+
config.externalSecretsMultipleConnections = true;
170+
});
171+
172+
it('should allow all requests', () => {
173+
const req = createMockRequest({ method: 'GET' });
174+
const res = createMockResponse();
175+
const next = createMockNextFunction();
176+
177+
controller.checkFeatureFlag(req, res, next);
178+
179+
expect(logger.warn).not.toHaveBeenCalled();
180+
expect(responseHelper.sendErrorResponse).not.toHaveBeenCalled();
181+
expect(next).toHaveBeenCalled();
182+
});
183+
184+
it('should allow POST requests with projectIds', () => {
185+
const req = createMockRequest({
186+
method: 'POST',
187+
body: { projectIds: ['project-1', 'project-2'] },
188+
});
189+
const res = createMockResponse();
190+
const next = createMockNextFunction();
191+
192+
controller.checkFeatureFlag(req, res, next);
193+
194+
expect(logger.warn).not.toHaveBeenCalled();
195+
expect(responseHelper.sendErrorResponse).not.toHaveBeenCalled();
196+
expect(next).toHaveBeenCalled();
197+
});
198+
});
199+
});
200+
});

packages/cli/src/modules/external-secrets.ee/external-secrets-manager.ee.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ export class ExternalSecretsManager implements IExternalSecretsManager {
281281

282282
@OnPubSubEvent('reload-external-secrets-providers')
283283
async reloadAllProviders(): Promise<void> {
284-
if (this.config.externalSecretsForProjects) {
284+
if (this.config.externalSecretsForProjects || this.config.externalSecretsMultipleConnections) {
285285
await this.reloadProvidersFromConnectionsRepo();
286286
return;
287287
}

packages/cli/src/modules/external-secrets.ee/external-secrets.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,8 @@ export class ExternalSecretsConfig {
1313
/** Whether to enable project-scoped external secrets */
1414
@Env('N8N_ENV_FEAT_EXTERNAL_SECRETS_FOR_PROJECTS')
1515
externalSecretsForProjects: boolean = false;
16+
17+
/** Whether to enable multiple connections to global secret providers */
18+
@Env('N8N_ENV_FEAT_EXTERNAL_SECRETS_MULTIPLE_CONNECTIONS')
19+
externalSecretsMultipleConnections: boolean = false;
1620
}

packages/cli/src/modules/external-secrets.ee/secrets-providers-completions.controller.ee.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,32 @@ export class SecretProvidersCompletionsController {
2121
}
2222

2323
@Middleware()
24-
checkFeatureFlag(_req: Request, res: Response, next: NextFunction) {
25-
if (!this.config.externalSecretsForProjects) {
26-
this.logger.warn('External secrets for projects feature is not enabled');
27-
sendErrorResponse(
28-
res,
29-
new ForbiddenError('External secrets for projects feature is not enabled'),
30-
);
31-
return;
24+
checkFeatureFlag(req: Request, res: Response, next: NextFunction) {
25+
const path = req.path;
26+
27+
if (path.startsWith('/secrets/global')) {
28+
const hasAccess =
29+
this.config.externalSecretsMultipleConnections || this.config.externalSecretsForProjects;
30+
31+
if (!hasAccess) {
32+
this.logger.warn('External secrets multiple connections feature is not enabled');
33+
sendErrorResponse(
34+
res,
35+
new ForbiddenError('External secrets multiple connections feature is not enabled'),
36+
);
37+
return;
38+
}
39+
} else if (path.startsWith('/secrets/project/')) {
40+
if (!this.config.externalSecretsForProjects) {
41+
this.logger.warn('External secrets for projects feature is not enabled');
42+
sendErrorResponse(
43+
res,
44+
new ForbiddenError('External secrets for projects feature is not enabled'),
45+
);
46+
return;
47+
}
3248
}
49+
3350
next();
3451
}
3552

packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.controller.ee.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,41 @@ export class SecretProvidersConnectionsController {
3737
}
3838

3939
@Middleware()
40-
checkFeatureFlag(_req: Request, res: Response, next: NextFunction) {
41-
if (!this.config.externalSecretsForProjects) {
42-
this.logger.warn('External secrets for projects feature is not enabled');
40+
checkFeatureFlag(req: Request, res: Response, next: NextFunction) {
41+
// Project-scoped connections require externalSecretsForProjects
42+
const isProjectScopedRequest =
43+
(req.method === 'POST' || req.method === 'PATCH') &&
44+
(req.body.projectIds as string[])?.length > 0;
45+
if (isProjectScopedRequest) {
46+
if (!this.config.externalSecretsForProjects) {
47+
this.logger.warn(
48+
'Tried to create a project-scoped external secret connection without feature flag enabled',
49+
);
50+
sendErrorResponse(
51+
res,
52+
new ForbiddenError(
53+
'Tried to create a project-scoped external secret connection without feature flag enabled',
54+
),
55+
);
56+
return;
57+
}
58+
next();
59+
return;
60+
}
61+
62+
// All other requests require at least one feature flag
63+
if (
64+
!this.config.externalSecretsForProjects &&
65+
!this.config.externalSecretsMultipleConnections
66+
) {
67+
this.logger.warn('Requested beta external secret endpoint without feature flag enabled');
4368
sendErrorResponse(
4469
res,
45-
new ForbiddenError('External secrets for projects feature is not enabled'),
70+
new ForbiddenError('Requested beta external secret endpoint without feature flag enabled'),
4671
);
4772
return;
4873
}
74+
4975
next();
5076
}
5177

packages/cli/src/modules/external-secrets.ee/secrets-providers-types.controller.ee.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@ export class SecretProvidersTypesController {
2323

2424
@Middleware()
2525
checkFeatureFlag(_req: Request, res: Response, next: NextFunction) {
26-
if (!this.config.externalSecretsForProjects) {
27-
this.logger.warn('External secrets for projects feature is not enabled');
26+
const hasAccess =
27+
this.config.externalSecretsMultipleConnections || this.config.externalSecretsForProjects;
28+
29+
if (!hasAccess) {
30+
this.logger.warn('Requested beta external secret endpoint without feature flag enabled');
2831
sendErrorResponse(
2932
res,
30-
new ForbiddenError('External secrets for projects feature is not enabled'),
33+
new ForbiddenError('Requested beta external secret endpoint without feature flag enabled'),
3134
);
3235
return;
3336
}

packages/frontend/editor-ui/src/app/router.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ const SettingsSourceControl = async () =>
7878
const SettingsExternalSecrets = async () => {
7979
const { check } = useEnvFeatureFlag();
8080

81-
if (check.value('EXTERNAL_SECRETS_FOR_PROJECTS')) {
81+
if (
82+
check.value('EXTERNAL_SECRETS_FOR_PROJECTS') ||
83+
check.value('EXTERNAL_SECRETS_MULTIPLE_CONNECTIONS')
84+
) {
8285
return await import(
8386
'@/features/integrations/secretsProviders.ee/views/SettingsSecretsProviders.ee.vue'
8487
);

packages/frontend/editor-ui/src/features/integrations/externalSecrets.ee/externalSecrets.ee.store.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,10 @@ export const useExternalSecretsStore = defineStore('externalSecrets', () => {
102102
async function fetchGlobalSecrets() {
103103
if (rbacStore.hasScope('externalSecret:list')) {
104104
try {
105-
state.secrets = checkDevFeatureFlag.value('EXTERNAL_SECRETS_FOR_PROJECTS')
105+
const betaFeatureEnabled =
106+
checkDevFeatureFlag.value('EXTERNAL_SECRETS_FOR_PROJECTS') ||
107+
checkDevFeatureFlag.value('EXTERNAL_SECRETS_MULTIPLE_CONNECTIONS');
108+
state.secrets = betaFeatureEnabled
106109
? await externalSecretsApi.getGlobalExternalSecrets(rootStore.restApiContext)
107110
: await externalSecretsApi.getExternalSecrets(rootStore.restApiContext);
108111
} catch {

packages/frontend/editor-ui/src/features/integrations/secretsProviders.ee/components/SecretsProviderConnectionCard.ee.vue

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@ import ProjectIcon from '@/features/collaboration/projects/components/ProjectIco
1818
import { splitName } from '@/features/collaboration/projects/projects.utils';
1919
import type { ProjectListItem } from '@/features/collaboration/projects/projects.types';
2020
import { isIconOrEmoji, type IconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
21+
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
2122
2223
const i18n = useI18n();
2324
const rbacStore = useRBACStore();
25+
const { check: checkDevFeatureFlag } = useEnvFeatureFlag();
26+
const isProjectScopedSecretsEnabled = checkDevFeatureFlag.value('EXTERNAL_SECRETS_FOR_PROJECTS');
2427
2528
const props = defineProps<{
2629
provider: SecretProviderConnection;
@@ -90,11 +93,13 @@ const actionDropdownOptions = computed(() => {
9093
label: i18n.baseText('generic.edit'),
9194
value: 'edit',
9295
},
93-
{
96+
];
97+
if (isProjectScopedSecretsEnabled) {
98+
options.push({
9499
label: i18n.baseText('settings.secretsProviderConnections.actions.share'),
95100
value: 'share',
96-
},
97-
];
101+
});
102+
}
98103
99104
if (canDelete.value) {
100105
options.push({

packages/frontend/editor-ui/src/features/integrations/secretsProviders.ee/components/SecretsProviderConnectionModal.ee.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ vi.mock('@/features/collaboration/projects/projects.store', () => ({
124124
useProjectsStore: vi.fn(() => mockProjectsStore),
125125
}));
126126

127+
vi.mock('@/features/shared/envFeatureFlag/useEnvFeatureFlag', () => ({
128+
useEnvFeatureFlag: vi.fn(() => ({
129+
check: {
130+
value: vi.fn((flag: string) => flag === 'EXTERNAL_SECRETS_FOR_PROJECTS'),
131+
},
132+
})),
133+
}));
134+
127135
const initialState = {
128136
[STORES.UI]: {
129137
modalsById: {

0 commit comments

Comments
 (0)