diff --git a/packages/@n8n/db/src/repositories/__tests__/secrets-provider-connection.repository.ee.test.ts b/packages/@n8n/db/src/repositories/__tests__/secrets-provider-connection.repository.ee.test.ts index ff99bae0fe7..fac44e9cd4b 100644 --- a/packages/@n8n/db/src/repositories/__tests__/secrets-provider-connection.repository.ee.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/secrets-provider-connection.repository.ee.test.ts @@ -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 => { + return mock({ + 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); diff --git a/packages/@n8n/db/src/repositories/secrets-provider-connection.repository.ee.ts b/packages/@n8n/db/src/repositories/secrets-provider-connection.repository.ee.ts index 11aa532e128..e7ef65e564a 100644 --- a/packages/@n8n/db/src/repositories/secrets-provider-connection.repository.ee.ts +++ b/packages/@n8n/db/src/repositories/secrets-provider-connection.repository.ee.ts @@ -25,21 +25,63 @@ export class SecretsProviderConnectionRepository extends Repository { - return await this.createQueryBuilder('connection') + async findGlobalConnections( + filters: { providerKeys?: Array } = {}, + ): Promise { + 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 { - 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 } = {}, + ): Promise { + 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(); } /** diff --git a/packages/cli/src/modules/external-secrets.ee/__tests__/provider-registry.service.test.ts b/packages/cli/src/modules/external-secrets.ee/__tests__/provider-registry.service.test.ts index 837f4009705..3be8825d4ef 100644 --- a/packages/cli/src/modules/external-secrets.ee/__tests__/provider-registry.service.test.ts +++ b/packages/cli/src/modules/external-secrets.ee/__tests__/provider-registry.service.test.ts @@ -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([]); }); }); diff --git a/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-providers-connections.service.ee.test.ts b/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-providers-connections.service.ee.test.ts index db347cff9f7..fac8d81de4e 100644 --- a/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-providers-connections.service.ee.test.ts +++ b/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-providers-connections.service.ee.test.ts @@ -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(); const mockExternalSecretsManager = mock(); const mockRedactionService = mock(); + const mockProviderRegistry = mock(); const mockEventService = mock(); 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, diff --git a/packages/cli/src/modules/external-secrets.ee/provider-registry.service.ts b/packages/cli/src/modules/external-secrets.ee/provider-registry.service.ts index a15616fbc2e..0fe2d1b8c1a 100644 --- a/packages/cli/src/modules/external-secrets.ee/provider-registry.service.ts +++ b/packages/cli/src/modules/external-secrets.ee/provider-registry.service.ts @@ -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 { + const result = new Map(); + 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[] { diff --git a/packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts b/packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts index 44966e865b4..bdb2c0ad875 100644 --- a/packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts +++ b/packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts @@ -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 { - return await this.repository.findGlobalConnections(); + const connectedProviderKeys = this.providerRegistry.getConnectedNames(); + + return await this.repository.findGlobalConnections({ + providerKeys: connectedProviderKeys, + }); } async getProjectCompletions(projectId: string): Promise { - return await this.repository.findByProjectId(projectId); + const connectedProviderKeys = this.providerRegistry.getConnectedNames(); + + return await this.repository.findByProjectId(projectId, { + providerKeys: connectedProviderKeys, + }); } async listConnectionsForProject(projectId: string): Promise { diff --git a/packages/cli/test/integration/database/repositories/secrets-provider-connection.repository.test.ts b/packages/cli/test/integration/database/repositories/secrets-provider-connection.repository.test.ts new file mode 100644 index 00000000000..332fb883f0e --- /dev/null +++ b/packages/cli/test/integration/database/repositories/secrets-provider-connection.repository.test.ts @@ -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(); + 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([]); + }); + }); +});