mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(core): Filter secrets provider autocomplete to connected vaults only (#25927)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fe6867ebc2
commit
bd92dabc5c
|
|
@ -1,4 +1,6 @@
|
|||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import random from 'lodash/random';
|
||||
|
||||
import { SecretsProviderConnection } from '../../entities';
|
||||
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
|
||||
|
|
@ -13,27 +15,22 @@ describe('SecretsProviderConnectionRepository', () => {
|
|||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
const createMockConnection = (
|
||||
overrides: Partial<SecretsProviderConnection> = {},
|
||||
): SecretsProviderConnection => {
|
||||
return mock<SecretsProviderConnection>({
|
||||
id: random(1, Number.MAX_SAFE_INTEGER),
|
||||
providerKey: 'myVault',
|
||||
type: 'vault',
|
||||
encryptedSettings: '',
|
||||
isEnabled: false,
|
||||
projectAccess: [],
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
it('should return all secrets provider connections', async () => {
|
||||
const mockConnections = [
|
||||
{
|
||||
providerKey: 'awsSecretsManager',
|
||||
type: 'awsSecretsManager',
|
||||
encryptedSettings: '{}',
|
||||
isEnabled: true,
|
||||
projectAccess: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
providerKey: 'vault',
|
||||
type: 'vault',
|
||||
encryptedSettings: '{}',
|
||||
isEnabled: false,
|
||||
projectAccess: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
const mockConnections = [createMockConnection(), createMockConnection()];
|
||||
|
||||
entityManager.find.mockResolvedValueOnce(mockConnections);
|
||||
|
||||
|
|
|
|||
|
|
@ -25,21 +25,63 @@ export class SecretsProviderConnectionRepository extends Repository<SecretsProvi
|
|||
}
|
||||
|
||||
/**
|
||||
* Find all global connections (connections with no project access entries)
|
||||
* Retrieves global connections, i.e., connections that are not assigned to any project.
|
||||
* A global connection has no associated entries in its projectAccess relation.
|
||||
*
|
||||
* @param filters - (Optional) Filters to apply to the query.
|
||||
* @param filters.providerKeys - (Optional) Limits results to connections of the specified provider keys.
|
||||
* @returns Promise resolving to all matching SecretsProviderConnection entities.
|
||||
*/
|
||||
async findGlobalConnections(): Promise<SecretsProviderConnection[]> {
|
||||
return await this.createQueryBuilder('connection')
|
||||
async findGlobalConnections(
|
||||
filters: { providerKeys?: Array<SecretsProviderConnection['providerKey']> } = {},
|
||||
): Promise<SecretsProviderConnection[]> {
|
||||
const { providerKeys } = filters;
|
||||
if (providerKeys && providerKeys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const connectionQuery = this.createQueryBuilder('connection')
|
||||
.leftJoin('connection.projectAccess', 'access')
|
||||
.where('access.secretsProviderConnectionId IS NULL')
|
||||
.getMany();
|
||||
.where('access.secretsProviderConnectionId IS NULL');
|
||||
|
||||
if (providerKeys) {
|
||||
connectionQuery.andWhere('connection.providerKey IN (:...providerKeys)', { providerKeys });
|
||||
}
|
||||
|
||||
return await connectionQuery.getMany();
|
||||
}
|
||||
|
||||
async findByProjectId(projectId: string): Promise<SecretsProviderConnection[]> {
|
||||
return await this.createQueryBuilder('connection')
|
||||
/**
|
||||
* Finds all secrets provider connections assigned to a given project.
|
||||
* Optionally filters connections by provider keys.
|
||||
*
|
||||
* This returns only those connections explicitly linked to the project,
|
||||
* not global connections (i.e., those without any project restriction).
|
||||
*
|
||||
* @param projectId - The ID of the project to filter on.
|
||||
* @param filters - (Optional) Filters to apply to the query.
|
||||
* @param filters.providerKeys - (Optional) Limits results to connections of the specified provider keys.
|
||||
* @returns Promise resolving to all matching SecretsProviderConnection entities.
|
||||
*/
|
||||
async findByProjectId(
|
||||
projectId: string,
|
||||
filters: { providerKeys?: Array<SecretsProviderConnection['providerKey']> } = {},
|
||||
): Promise<SecretsProviderConnection[]> {
|
||||
const { providerKeys } = filters;
|
||||
if (providerKeys && providerKeys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const connectionQuery = this.createQueryBuilder('connection')
|
||||
.innerJoinAndSelect('connection.projectAccess', 'projectAccess')
|
||||
.leftJoinAndSelect('projectAccess.project', 'project')
|
||||
.where('projectAccess.projectId = :projectId', { projectId })
|
||||
.getMany();
|
||||
.where('projectAccess.projectId = :projectId', { projectId });
|
||||
|
||||
if (providerKeys) {
|
||||
connectionQuery.andWhere('connection.providerKey IN (:...providerKeys)', { providerKeys });
|
||||
}
|
||||
|
||||
return await connectionQuery.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -119,13 +119,42 @@ describe('ProviderRegistry', () => {
|
|||
|
||||
const result = registry.getConnected();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(dummyProvider);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('dummy')).toBe(dummyProvider);
|
||||
});
|
||||
|
||||
it('should return empty array when no providers are connected', () => {
|
||||
registry.add('dummy', dummyProvider);
|
||||
registry.add('another', anotherProvider);
|
||||
|
||||
const result = registry.getConnected();
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConnectedNames', () => {
|
||||
it('should return all connected provider names', async () => {
|
||||
await dummyProvider.init({ connected: true, connectedAt: new Date(), settings: {} });
|
||||
await dummyProvider.connect();
|
||||
|
||||
await anotherProvider.init({ connected: true, connectedAt: new Date(), settings: {} });
|
||||
anotherProvider.setState('error', new Error('Test error'));
|
||||
|
||||
registry.add('dummy', dummyProvider);
|
||||
registry.add('another', anotherProvider);
|
||||
|
||||
const result = registry.getConnectedNames();
|
||||
|
||||
expect(result).toEqual(['dummy']);
|
||||
});
|
||||
|
||||
it('should return empty array when no providers are connected', () => {
|
||||
registry.add('dummy', dummyProvider);
|
||||
registry.add('another', anotherProvider);
|
||||
|
||||
const result = registry.getConnectedNames();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { IDataObject, INodeProperties } from 'n8n-workflow';
|
||||
import { mockLogger } from '@n8n/backend-test-utils';
|
||||
import type {
|
||||
ProjectSecretsProviderAccessRepository,
|
||||
|
|
@ -6,11 +5,13 @@ import type {
|
|||
SecretsProviderConnectionRepository,
|
||||
} from '@n8n/db';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IDataObject, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { CREDENTIAL_BLANKING_VALUE } from '@/constants';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import type { ExternalSecretsManager } from '@/modules/external-secrets.ee/external-secrets-manager.ee';
|
||||
import type { ExternalSecretsProviderRegistry } from '@/modules/external-secrets.ee/provider-registry.service';
|
||||
import type { RedactionService } from '@/modules/external-secrets.ee/redaction.service.ee';
|
||||
import { SecretsProvidersConnectionsService } from '@/modules/external-secrets.ee/secrets-providers-connections.service.ee';
|
||||
import type { SecretsProvider } from '@/modules/external-secrets.ee/types';
|
||||
|
|
@ -20,6 +21,7 @@ describe('SecretsProvidersConnectionsService', () => {
|
|||
const mockProjectAccessRepository = mock<ProjectSecretsProviderAccessRepository>();
|
||||
const mockExternalSecretsManager = mock<ExternalSecretsManager>();
|
||||
const mockRedactionService = mock<RedactionService>();
|
||||
const mockProviderRegistry = mock<ExternalSecretsProviderRegistry>();
|
||||
const mockEventService = mock<EventService>();
|
||||
const mockCipher = {
|
||||
encrypt: jest.fn((data: IDataObject) => JSON.stringify(data)),
|
||||
|
|
@ -30,6 +32,7 @@ describe('SecretsProvidersConnectionsService', () => {
|
|||
mockLogger(),
|
||||
mockRepository,
|
||||
mockProjectAccessRepository,
|
||||
mockProviderRegistry,
|
||||
mockCipher as any,
|
||||
mockExternalSecretsManager,
|
||||
mockRedactionService,
|
||||
|
|
|
|||
|
|
@ -30,8 +30,18 @@ export class ExternalSecretsProviderRegistry {
|
|||
return new Map(this.providers);
|
||||
}
|
||||
|
||||
getConnected(): SecretsProvider[] {
|
||||
return Array.from(this.providers.values()).filter((p) => p.state === 'connected');
|
||||
getConnected(): Map<string, SecretsProvider> {
|
||||
const result = new Map<string, SecretsProvider>();
|
||||
for (const [name, provider] of this.providers) {
|
||||
if (provider.state === 'connected') {
|
||||
result.set(name, provider);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getConnectedNames(): string[] {
|
||||
return Array.from(this.getConnected().keys());
|
||||
}
|
||||
|
||||
getNames(): string[] {
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import {
|
|||
import { Service } from '@n8n/di';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
|
|
@ -25,12 +25,15 @@ import { ExternalSecretsManager } from '@/modules/external-secrets.ee/external-s
|
|||
import { RedactionService } from '@/modules/external-secrets.ee/redaction.service.ee';
|
||||
import { SecretsProvidersResponses } from '@/modules/external-secrets.ee/secrets-providers.responses.ee';
|
||||
|
||||
import { ExternalSecretsProviderRegistry } from './provider-registry.service';
|
||||
|
||||
@Service()
|
||||
export class SecretsProvidersConnectionsService {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly repository: SecretsProviderConnectionRepository,
|
||||
private readonly projectAccessRepository: ProjectSecretsProviderAccessRepository,
|
||||
private readonly providerRegistry: ExternalSecretsProviderRegistry,
|
||||
private readonly cipher: Cipher,
|
||||
private readonly externalSecretsManager: ExternalSecretsManager,
|
||||
private readonly redactionService: RedactionService,
|
||||
|
|
@ -179,11 +182,19 @@ export class SecretsProvidersConnectionsService {
|
|||
}
|
||||
|
||||
async getGlobalCompletions(): Promise<SecretsProviderConnection[]> {
|
||||
return await this.repository.findGlobalConnections();
|
||||
const connectedProviderKeys = this.providerRegistry.getConnectedNames();
|
||||
|
||||
return await this.repository.findGlobalConnections({
|
||||
providerKeys: connectedProviderKeys,
|
||||
});
|
||||
}
|
||||
|
||||
async getProjectCompletions(projectId: string): Promise<SecretsProviderConnection[]> {
|
||||
return await this.repository.findByProjectId(projectId);
|
||||
const connectedProviderKeys = this.providerRegistry.getConnectedNames();
|
||||
|
||||
return await this.repository.findByProjectId(projectId, {
|
||||
providerKeys: connectedProviderKeys,
|
||||
});
|
||||
}
|
||||
|
||||
async listConnectionsForProject(projectId: string): Promise<SecretsProviderConnection[]> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
import { LicenseState } from '@n8n/backend-common';
|
||||
import { createTeamProject, testDb } from '@n8n/backend-test-utils';
|
||||
import type { Project } from '@n8n/db';
|
||||
import {
|
||||
ProjectSecretsProviderAccessRepository,
|
||||
SecretsProviderConnectionRepository,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { Cipher } from 'n8n-core';
|
||||
|
||||
describe('SecretsProviderConnectionRepository', () => {
|
||||
let connectionRepository: SecretsProviderConnectionRepository;
|
||||
let projectAccessRepository: ProjectSecretsProviderAccessRepository;
|
||||
|
||||
let project1: Project;
|
||||
let project2: Project;
|
||||
|
||||
beforeAll(async () => {
|
||||
const licenseMock = mock<LicenseState>();
|
||||
licenseMock.isLicensed.mockReturnValue(true);
|
||||
Container.set(LicenseState, licenseMock);
|
||||
|
||||
await testDb.init();
|
||||
|
||||
connectionRepository = Container.get(SecretsProviderConnectionRepository);
|
||||
projectAccessRepository = Container.get(ProjectSecretsProviderAccessRepository);
|
||||
|
||||
project1 = await createTeamProject('Project 1');
|
||||
project2 = await createTeamProject('Project 2');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['SecretsProviderConnection', 'ProjectSecretsProviderAccess']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
async function createConnection(providerKey: string, type: string, projectIds: string[] = []) {
|
||||
const cipher = Container.get(Cipher);
|
||||
const encryptedSettings = cipher.encrypt({});
|
||||
|
||||
const connection = await connectionRepository.save(
|
||||
connectionRepository.create({
|
||||
providerKey,
|
||||
type,
|
||||
encryptedSettings,
|
||||
isEnabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
await projectAccessRepository.save(
|
||||
projectIds.map((projectId) =>
|
||||
projectAccessRepository.create({
|
||||
secretsProviderConnectionId: connection.id,
|
||||
projectId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
describe('findGlobalConnections', () => {
|
||||
it('returns only connections without project access', async () => {
|
||||
await Promise.all([
|
||||
createConnection('global1', 'awsSecretsManager'),
|
||||
createConnection('global2', 'hashicorpVault'),
|
||||
createConnection('project1', 'awsSecretsManager', [project1.id]),
|
||||
]);
|
||||
|
||||
const connections = await connectionRepository.findGlobalConnections();
|
||||
|
||||
expect(connections).toHaveLength(2);
|
||||
expect(connections.map((connection) => connection.providerKey).sort()).toEqual([
|
||||
'global1',
|
||||
'global2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by provider keys when provided', async () => {
|
||||
await Promise.all([
|
||||
createConnection('globalAws', 'awsSecretsManager'),
|
||||
createConnection('globalVault', 'hashicorpVault'),
|
||||
createConnection('globalGcp', 'gcpSecretsManager'),
|
||||
]);
|
||||
|
||||
const connections = await connectionRepository.findGlobalConnections({
|
||||
providerKeys: ['globalAws', 'globalVault'],
|
||||
});
|
||||
|
||||
expect(connections).toHaveLength(2);
|
||||
expect(connections.map((connection) => connection.providerKey).sort()).toEqual([
|
||||
'globalAws',
|
||||
'globalVault',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when no global connections exist', async () => {
|
||||
await createConnection('projectOnly', 'awsSecretsManager', [project1.id]);
|
||||
|
||||
const connections = await connectionRepository.findGlobalConnections();
|
||||
|
||||
expect(connections).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when providerKeys filter is an empty array', async () => {
|
||||
await Promise.all([
|
||||
createConnection('globalAws', 'awsSecretsManager'),
|
||||
createConnection('globalVault', 'hashicorpVault'),
|
||||
createConnection('globalGcp', 'gcpSecretsManager'),
|
||||
]);
|
||||
|
||||
const connections = await connectionRepository.findGlobalConnections({
|
||||
providerKeys: [],
|
||||
});
|
||||
|
||||
expect(connections).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByProjectId', () => {
|
||||
it('returns only connections assigned to the project', async () => {
|
||||
await Promise.all([
|
||||
createConnection('global', 'awsSecretsManager'),
|
||||
createConnection('proj1A', 'awsSecretsManager', [project1.id]),
|
||||
createConnection('proj1B', 'hashicorpVault', [project1.id]),
|
||||
createConnection('proj2A', 'gcpSecretsManager', [project2.id]),
|
||||
]);
|
||||
|
||||
const connections = await connectionRepository.findByProjectId(project1.id);
|
||||
|
||||
expect(connections).toHaveLength(2);
|
||||
expect(connections.map((connection) => connection.providerKey).sort()).toEqual([
|
||||
'proj1A',
|
||||
'proj1B',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by provider keys when provided', async () => {
|
||||
await Promise.all([
|
||||
createConnection('projAws', 'awsSecretsManager', [project1.id]),
|
||||
createConnection('projVault', 'hashicorpVault', [project1.id]),
|
||||
createConnection('projGcp', 'gcpSecretsManager', [project1.id]),
|
||||
]);
|
||||
|
||||
const connections = await connectionRepository.findByProjectId(project1.id, {
|
||||
providerKeys: ['projAws', 'projGcp'],
|
||||
});
|
||||
|
||||
expect(connections).toHaveLength(2);
|
||||
expect(connections.map((connection) => connection.providerKey).sort()).toEqual([
|
||||
'projAws',
|
||||
'projGcp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when no connections exist for project', async () => {
|
||||
await createConnection('otherProject', 'awsSecretsManager', [project2.id]);
|
||||
|
||||
const connections = await connectionRepository.findByProjectId(project1.id);
|
||||
|
||||
expect(connections).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when providerKeys filter is an empty array', async () => {
|
||||
await Promise.all([
|
||||
createConnection('projAws', 'awsSecretsManager', [project1.id]),
|
||||
createConnection('projVault', 'hashicorpVault', [project1.id]),
|
||||
createConnection('projGcp', 'gcpSecretsManager', [project1.id]),
|
||||
]);
|
||||
|
||||
const connections = await connectionRepository.findByProjectId(project1.id, {
|
||||
providerKeys: [],
|
||||
});
|
||||
|
||||
expect(connections).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user