Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,6 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [
'projectVariable:create',
'projectVariable:update',
'projectVariable:delete',
'externalSecretsProvider:create',
'externalSecretsProvider:read',
'externalSecretsProvider:update',
'externalSecretsProvider:delete',
'externalSecretsProvider:list',
'externalSecretsProvider:sync',
'externalSecret:list',
];

export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
CreateSecretsProviderConnectionDto,
UpdateSecretsProviderConnectionDto,
type ReloadSecretProviderConnectionResponse,
type SecretProviderTypeResponse,
type TestSecretProviderConnectionResponse,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
Expand All @@ -19,6 +20,7 @@ import {
} from '@n8n/decorators';
import type { NextFunction, Request, Response } from 'express';

import { ExternalSecretsProviders } from './external-secrets-providers.ee';
import { ExternalSecretsConfig } from './external-secrets.config';
import { SecretsProvidersConnectionsService } from './secrets-providers-connections.service.ee';
import type { SecretsProvidersResponses } from './secrets-providers.responses.ee';
Expand All @@ -32,6 +34,7 @@ export class SecretProvidersProjectController {
private readonly config: ExternalSecretsConfig,
private readonly logger: Logger,
private readonly connectionsService: SecretsProvidersConnectionsService,
private readonly secretsProviders: ExternalSecretsProviders,
) {
this.logger = this.logger.scoped('external-secrets');
}
Expand All @@ -49,6 +52,17 @@ export class SecretProvidersProjectController {
next();
}

@Get('/:projectId/types')
@ProjectScope('externalSecretsProvider:list')
listSecretProviderTypes(): SecretProviderTypeResponse[] {
this.logger.debug('List provider connection types for project');
const allProviders = this.secretsProviders.getAllProviders();
return Object.values(allProviders).map((providerClass) => {
const provider = new providerClass();
return this.secretsProviders.toProviderTypeResponse(provider);
});
}

@Post('/:projectId/connections')
@ProjectScope('externalSecretsProvider:create')
async createConnection(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { LicenseState } from '@n8n/backend-common';
import { createTeamProject, mockInstance, testDb } from '@n8n/backend-test-utils';
import type { Project } from '@n8n/db';
import {
createTeamProject,
linkUserToProject,
mockInstance,
testDb,
} from '@n8n/backend-test-utils';
import type { Project, User } from '@n8n/db';
import {
ProjectSecretsProviderAccessRepository,
SecretsProviderConnectionRepository,
Expand All @@ -10,6 +15,8 @@ import { mock } from 'jest-mock-extended';
import { Cipher } from 'n8n-core';
import type { Response } from 'superagent';

import type { SecretProviderTypeResponse } from '@n8n/api-types';

import { ExternalSecretsProviders } from '@/modules/external-secrets.ee/external-secrets-providers.ee';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';

Expand All @@ -19,6 +26,7 @@ import {
createDummyProvider,
} from '../../shared/external-secrets/utils';
import { createAdmin, createMember, createOwner } from '../shared/db/users';
import { createCustomRoleWithScopeSlugs } from '../shared/db/roles';
import type { SuperAgentTest } from '../shared/types';
import { setupTestServer } from '../shared/utils';

Expand Down Expand Up @@ -795,4 +803,83 @@ describe('Secret Providers Project API', () => {
);
});
});

describe('GET /secret-providers/projects/:projectId/types', () => {
const FORBIDDEN_MESSAGE = 'User is missing a scope required to perform this action';

describe('Authorization', () => {
let memberWithListScope: User;
let memberWithoutListScope: User;
let memberWithListScopeAgent: SuperAgentTest;
let memberWithoutListScopeAgent: SuperAgentTest;

beforeAll(async () => {
memberWithListScope = await createMember();
memberWithoutListScope = await createMember();

const roleWithList = await createCustomRoleWithScopeSlugs(
['externalSecretsProvider:list'],
{ roleType: 'project', displayName: 'External Secrets Lister' },
);

const roleWithoutList = await createCustomRoleWithScopeSlugs(
['externalSecretsProvider:read'],
{ roleType: 'project', displayName: 'External Secrets Reader Only' },
);

await linkUserToProject(memberWithListScope, teamProject1, roleWithList.slug);
await linkUserToProject(memberWithoutListScope, teamProject1, roleWithoutList.slug);

memberWithListScopeAgent = testServer.authAgentFor(memberWithListScope);
memberWithoutListScopeAgent = testServer.authAgentFor(memberWithoutListScope);
});

test.each([
{ role: 'owner', allowed: true },
{ role: 'admin', allowed: true },
{ role: 'member', allowed: false },
])(
'should allow=$allowed for global $role to list provider types',
async ({ role, allowed }) => {
const agents = { owner: ownerAgent, admin: adminAgent, member: memberAgent };
const response = await agents[role]
.get(`/secret-providers/projects/${teamProject1.id}/types`)
.expect(allowed ? 200 : 403);

if (!allowed) {
expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
}
},
);

it('should allow project member with externalSecretsProvider:list scope', async () => {
await memberWithListScopeAgent
.get(`/secret-providers/projects/${teamProject1.id}/types`)
.expect(200);
});

it('should forbid project member without externalSecretsProvider:list scope', async () => {
const response = await memberWithoutListScopeAgent
.get(`/secret-providers/projects/${teamProject1.id}/types`)
.expect(403);

expect(response.body.message).toBe(FORBIDDEN_MESSAGE);
});
});

describe('response', () => {
it('should return all registered provider types', async () => {
const response = await ownerAgent
.get(`/secret-providers/projects/${teamProject1.id}/types`)
.expect(200);

const { data } = response.body as { data: SecretProviderTypeResponse[] };
expect(data).toBeInstanceOf(Array);
expect(data).toHaveLength(3);
expect(data.map((p) => p.type)).toEqual(
expect.arrayContaining(['dummy', 'awsSecretsManager', 'gcpSecretsManager']),
);
});
});
});
});
11 changes: 11 additions & 0 deletions packages/frontend/@n8n/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2405,6 +2405,17 @@
"projectRoles.type.sourceControl": "Source control",
"projectRoles.type.dataTable": "Data tables",
"projectRoles.type.projectVariable": "Project variables",
"projectRoles.type.externalSecretsProvider": "Secret stores",
"projectRoles.externalSecretsProvider:read": "View",
"projectRoles.externalSecretsProvider:read.tooltip": "View secret store connections and see available secret names",
"projectRoles.externalSecretsProvider:create": "Create",
"projectRoles.externalSecretsProvider:create.tooltip": "Add new secret store connections to the project",
"projectRoles.externalSecretsProvider:update": "Edit",
"projectRoles.externalSecretsProvider:update.tooltip": "Edit secret store connection settings",
"projectRoles.externalSecretsProvider:delete": "Delete",
"projectRoles.externalSecretsProvider:delete.tooltip": "Remove secret store connections from the project",
"projectRoles.externalSecretsProvider:sync": "Sync",
"projectRoles.externalSecretsProvider:sync.tooltip": "Manually trigger a sync to refresh secrets from the provider",
"projectRoles.literal.system": "System",
"projectRoles.literal.custom": "Custom",
"projectRoles.action.cancel": "Cancel",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,80 @@ export const getProjectSecretProviderConnectionsByProjectId = async (
`/secret-providers/projects/${projectId}/connections`,
);
};

export const getProjectSecretProviderTypes = async (
context: IRestApiContext,
projectId: string,
): Promise<SecretProviderTypeResponse[]> => {
return await makeRestApiRequest(context, 'GET', `/secret-providers/projects/${projectId}/types`);
};

export const getProjectSecretProviderConnectionByKey = async (
context: IRestApiContext,
projectId: string,
providerKey: string,
): Promise<SecretProviderConnection> => {
return await makeRestApiRequest(
context,
'GET',
`/secret-providers/projects/${projectId}/connections/${providerKey}`,
);
};

export const createProjectSecretProviderConnection = async (
context: IRestApiContext,
projectId: string,
data: {
providerKey: string;
type: string;
projectIds: string[];
settings: Record<string, unknown>;
},
): Promise<SecretProviderConnection> => {
return await makeRestApiRequest(
context,
'POST',
`/secret-providers/projects/${projectId}/connections`,
data,
);
};

export const updateProjectSecretProviderConnection = async (
context: IRestApiContext,
projectId: string,
providerKey: string,
data: {
settings: Record<string, unknown>;
},
): Promise<SecretProviderConnection> => {
return await makeRestApiRequest(
context,
'PATCH',
`/secret-providers/projects/${projectId}/connections/${providerKey}`,
data,
);
};

export const testProjectSecretProviderConnection = async (
context: IRestApiContext,
projectId: string,
providerKey: string,
): Promise<TestSecretProviderConnectionResponse> => {
return await makeRestApiRequest(
context,
'POST',
`/secret-providers/projects/${projectId}/connections/${providerKey}/test`,
);
};

export const deleteProjectSecretProviderConnection = async (
context: IRestApiContext,
projectId: string,
providerKey: string,
): Promise<void> => {
return await makeRestApiRequest(
context,
'DELETE',
`/secret-providers/projects/${projectId}/connections/${providerKey}`,
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -262,11 +262,11 @@ watch([currentPage, itemsPerPage], async () => {
await fetchSecretsForCurrentPage();
});

// Fetch project secret providers when currentProjectId is available
// Fetch project secret providers when currentProjectId is available and section is visible
watch(
() => projectsStore.currentProjectId,
async (newProjectId) => {
if (newProjectId && showExternalSecretsSection.value) {
[() => projectsStore.currentProjectId, showExternalSecretsSection],
async ([newProjectId, showSection]) => {
if (newProjectId && showSection) {
await fetchProjectSecretConnections();
}
},
Expand All @@ -276,7 +276,7 @@ watch(
onMounted(async () => {
if (!showExternalSecretsSection.value) return;
await Promise.all([
secretsProviders.fetchProviderTypes(),
secretsProviders.fetchProviderTypes(projectsStore.currentProjectId ?? undefined),
secretsProviders.fetchActiveConnections(),
]);
if (canCreateGlobalSecretsStore.value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('RoleHoverPopover', () => {
it('should display permission count', () => {
const { getByText } = renderComponent();

expect(getByText('3/32 permissions')).toBeInTheDocument();
expect(getByText('3/37 permissions')).toBeInTheDocument();
});

it('should display role description when available', () => {
Expand Down
Loading