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:
Ali Elkhateeb 2026-02-19 11:48:20 +02:00 committed by GitHub
parent fe6867ebc2
commit bd92dabc5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 313 additions and 37 deletions

View File

@ -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);

View File

@ -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();
}
/**

View File

@ -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([]);
});
});

View File

@ -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,

View File

@ -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[] {

View File

@ -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[]> {

View File

@ -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([]);
});
});
});