diff --git a/packages/@n8n/db/src/entities/credential-dependency-entity.ts b/packages/@n8n/db/src/entities/credential-dependency-entity.ts new file mode 100644 index 00000000000..a05ece3a8d6 --- /dev/null +++ b/packages/@n8n/db/src/entities/credential-dependency-entity.ts @@ -0,0 +1,37 @@ +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Relation, + Unique, +} from '@n8n/typeorm'; + +import { WithCreatedAt } from './abstract-entity'; +import type { CredentialsEntity } from './credentials-entity'; + +export type CredentialDependencyType = 'externalSecretProvider'; + +@Entity({ name: 'credential_dependency' }) +@Index(['dependencyType', 'dependencyId']) +@Unique(['credentialId', 'dependencyType', 'dependencyId']) +export class CredentialDependency extends WithCreatedAt { + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: 36 }) + @Index() + credentialId: string; + + @Column({ length: 64 }) + dependencyType: CredentialDependencyType; + + @Column({ length: 255 }) + dependencyId: string; + + @ManyToOne('CredentialsEntity', { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'credentialId' }) + credential: Relation; +} diff --git a/packages/@n8n/db/src/entities/index.ts b/packages/@n8n/db/src/entities/index.ts index 41503961b85..acc2abbd928 100644 --- a/packages/@n8n/db/src/entities/index.ts +++ b/packages/@n8n/db/src/entities/index.ts @@ -4,6 +4,10 @@ import { ApiKey } from './api-key'; import { AuthIdentity } from './auth-identity'; import { AuthProviderSyncHistory } from './auth-provider-sync-history'; import { BinaryDataFile, SourceTypeSchema, type SourceType } from './binary-data-file'; +import { + CredentialDependency, + type CredentialDependencyType, +} from './credential-dependency-entity'; import { CredentialsEntity } from './credentials-entity'; import { ExecutionAnnotation } from './execution-annotation.ee'; import { ExecutionData } from './execution-data'; @@ -51,6 +55,8 @@ export { WebhookEntity, AuthIdentity, CredentialsEntity, + CredentialDependency, + type CredentialDependencyType, Folder, Project, ProjectRelation, @@ -92,6 +98,7 @@ export const entities = { WebhookEntity, AuthIdentity, CredentialsEntity, + CredentialDependency, Folder, Project, ProjectRelation, diff --git a/packages/@n8n/db/src/migrations/common/1773000000000-CreateCredentialDependencyTable.ts b/packages/@n8n/db/src/migrations/common/1773000000000-CreateCredentialDependencyTable.ts new file mode 100644 index 00000000000..69210d48e43 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1773000000000-CreateCredentialDependencyTable.ts @@ -0,0 +1,203 @@ +import { Container } from '@n8n/di'; +import { Cipher } from 'n8n-core'; + +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const credentialDependencyTable = 'credential_dependency'; +const externalSecretProviderDependencyType = 'externalSecretProvider'; +const providerKeyPattern = '[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*'; + +type CredentialRow = { + id: string; + data: string; +}; + +export class CreateCredentialDependencyTable1773000000000 implements ReversibleMigration { + private readonly cipher = Container.get(Cipher); + + async up({ + schemaBuilder: { createTable, column, createIndex }, + escape, + runQuery, + runInBatches, + logger, + migrationName, + }: MigrationContext) { + await createTable(credentialDependencyTable) + .withColumns( + column('id').int.primary.autoGenerate2, + column('credentialId').varchar(36).notNull, + column('dependencyType').varchar(64).notNull, + column('dependencyId').varchar(255).notNull, + ) + .withForeignKey('credentialId', { + tableName: 'credentials_entity', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withIndexOn(['credentialId']) + .withIndexOn(['dependencyType', 'dependencyId']).withCreatedAt; + + await createIndex( + credentialDependencyTable, + ['credentialId', 'dependencyType', 'dependencyId'], + true, + ); + + const credentialsTable = escape.tableName('credentials_entity'); + const query = `SELECT c.id AS id, c.data AS data FROM ${credentialsTable} c ORDER BY c.id`; + const providerIdByKey = await this.loadProviderIdByKey(runQuery, escape); + + let processedCount = 0; + let insertedCount = 0; + + await runInBatches(query, async (rows) => { + if (rows.length === 0) return; + + const batchDependencies = rows.flatMap((row) => { + const providerKeys = this.extractProviderKeysFromCredentialData(row.data); + const providerIds = providerKeys + .map((providerKey) => providerIdByKey.get(providerKey)) + .filter((providerId): providerId is string => providerId !== undefined); + return providerIds.map((providerId) => ({ + credentialId: row.id, + dependencyType: externalSecretProviderDependencyType, + dependencyId: providerId, + })); + }); + + processedCount += rows.length; + + if (batchDependencies.length === 0) { + return; + } + + await this.insertDependencies(batchDependencies, escape, runQuery); + insertedCount += batchDependencies.length; + }); + + logger.info( + `[${migrationName}] Backfilled credential dependencies for ${processedCount} credentials. Inserted ${insertedCount} dependencies.`, + ); + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable(credentialDependencyTable); + } + + private extractProviderKeysFromCredentialData(encryptedCredentialData: string): string[] { + const decrypted = this.tryDecryptCredentialData(encryptedCredentialData); + if (decrypted === null) return []; + return this.extractProviderKeysFromDecryptedData(decrypted); + } + + private extractProviderKeysFromDecryptedData(decryptedCredentialData: unknown): string[] { + const uniqueKeys = new Set(); + const valuesToScan: unknown[] = [decryptedCredentialData]; + + while (valuesToScan.length > 0) { + const currentValue = valuesToScan.pop(); + if (typeof currentValue === 'string') { + if (!currentValue.includes('$secrets')) continue; + for (const dependencyKey of this.extractProviderKeys(currentValue)) { + uniqueKeys.add(dependencyKey); + } + continue; + } + + if (Array.isArray(currentValue)) { + for (const value of currentValue as unknown[]) { + valuesToScan.push(value); + } + continue; + } + + if (typeof currentValue === 'object' && currentValue !== null) { + for (const value of Object.values(currentValue as Record)) { + valuesToScan.push(value); + } + } + } + + return [...uniqueKeys]; + } + + private tryDecryptCredentialData(encryptedCredentialData: string): unknown { + try { + const decrypted = this.cipher.decrypt(encryptedCredentialData); + return JSON.parse(decrypted) as unknown; + } catch { + return null; + } + } + + private extractProviderKeys(expression: string): string[] { + const providerKeys = new Set(); + const expressionBlocks = expression.matchAll(/\{\{(.*?)\}\}/gs); + + for (const block of expressionBlocks) { + const expressionContent = block[1]; + + const dotMatches = expressionContent.matchAll( + new RegExp(`\\$secrets\\.(${providerKeyPattern})`, 'g'), + ); + for (const match of dotMatches) { + providerKeys.add(match[1]); + } + + const bracketMatches = expressionContent.matchAll( + new RegExp(`\\$secrets\\[['"](${providerKeyPattern})['"]\\]`, 'g'), + ); + for (const match of bracketMatches) { + providerKeys.add(match[1]); + } + } + + return [...providerKeys]; + } + + private async insertDependencies( + dependencies: Array<{ + credentialId: string; + dependencyType: string; + dependencyId: string; + }>, + escape: MigrationContext['escape'], + runQuery: MigrationContext['runQuery'], + ) { + const dependencyTable = escape.tableName(credentialDependencyTable); + const credentialIdColumn = escape.columnName('credentialId'); + const dependencyTypeColumn = escape.columnName('dependencyType'); + const dependencyIdColumn = escape.columnName('dependencyId'); + + const namedParameters: Record = {}; + const valuesSql = dependencies + .map((dependency, index) => { + namedParameters[`credentialId${index}`] = dependency.credentialId; + namedParameters[`dependencyType${index}`] = dependency.dependencyType; + namedParameters[`dependencyId${index}`] = dependency.dependencyId; + + return `(:credentialId${index}, :dependencyType${index}, :dependencyId${index})`; + }) + .join(', '); + + await runQuery( + `INSERT INTO ${dependencyTable} (${credentialIdColumn}, ${dependencyTypeColumn}, ${dependencyIdColumn}) VALUES ${valuesSql} ON CONFLICT (${credentialIdColumn}, ${dependencyTypeColumn}, ${dependencyIdColumn}) DO NOTHING;`, + namedParameters, + ); + } + + private async loadProviderIdByKey( + runQuery: MigrationContext['runQuery'], + escape: MigrationContext['escape'], + ): Promise> { + const providerTable = escape.tableName('secrets_provider_connection'); + const idColumn = escape.columnName('id'); + const providerKeyColumn = escape.columnName('providerKey'); + const rows = await runQuery>( + `SELECT ${idColumn} AS ${idColumn}, ${providerKeyColumn} AS ${providerKeyColumn} FROM ${providerTable}`, + ); + + return new Map(rows.map(({ id, providerKey }) => [providerKey, id.toString()])); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 20c4ee15795..4238a8d1c0b 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -152,6 +152,7 @@ import { AddSuggestedPromptsToAgentTable1772000000000 } from '../common/17720000 import { AddRoleColumnToProjectSecretsProviderAccess1772619247761 } from '../common/1772619247761-AddRoleColumnToProjectSecretsProviderAccess'; import { ChangeWorkflowPublishedVersionFKsToRestrict1772619247762 } from '../common/1772619247762-ChangeWorkflowPublishedVersionFKsToRestrict'; import { AddTypeToChatHubSessions1772700000000 } from '../common/1772700000000-AddTypeToChatHubSessions'; +import { CreateCredentialDependencyTable1773000000000 } from '../common/1773000000000-CreateCredentialDependencyTable'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -309,4 +310,5 @@ export const postgresMigrations: Migration[] = [ AddRoleColumnToProjectSecretsProviderAccess1772619247761, ChangeWorkflowPublishedVersionFKsToRestrict1772619247762, AddTypeToChatHubSessions1772700000000, + CreateCredentialDependencyTable1773000000000, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 2e17ce1a981..02cafd3fa3e 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -146,6 +146,7 @@ import { AddSuggestedPromptsToAgentTable1772000000000 } from '../common/17720000 import { AddRoleColumnToProjectSecretsProviderAccess1772619247761 } from '../common/1772619247761-AddRoleColumnToProjectSecretsProviderAccess'; import { ChangeWorkflowPublishedVersionFKsToRestrict1772619247762 } from '../common/1772619247762-ChangeWorkflowPublishedVersionFKsToRestrict'; import { AddTypeToChatHubSessions1772700000000 } from '../common/1772700000000-AddTypeToChatHubSessions'; +import { CreateCredentialDependencyTable1773000000000 } from '../common/1773000000000-CreateCredentialDependencyTable'; import type { Migration } from '../migration-types'; const sqliteMigrations: Migration[] = [ @@ -297,6 +298,7 @@ const sqliteMigrations: Migration[] = [ AddRoleColumnToProjectSecretsProviderAccess1772619247761, ChangeWorkflowPublishedVersionFKsToRestrict1772619247762, AddTypeToChatHubSessions1772700000000, + CreateCredentialDependencyTable1773000000000, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/db/src/repositories/__tests__/credential-dependency.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/credential-dependency.repository.test.ts new file mode 100644 index 00000000000..06656bfd04c --- /dev/null +++ b/packages/@n8n/db/src/repositories/__tests__/credential-dependency.repository.test.ts @@ -0,0 +1,176 @@ +import { Container } from '@n8n/di'; +import { In } from '@n8n/typeorm'; + +import { CredentialDependency } from '../../entities'; +import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager'; +import { + addCredentialDependencyExistsFilter, + CredentialDependencyRepository, +} from '../credential-dependency.repository'; + +describe('CredentialDependencyRepository', () => { + const entityManager = mockEntityManager(CredentialDependency); + const repository = Container.get(CredentialDependencyRepository); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('findCredentialIdsByDependencyId', () => { + it('returns matching credential ids', async () => { + entityManager.find.mockResolvedValueOnce([ + { credentialId: 'cred-1' } as CredentialDependency, + { credentialId: 'cred-2' } as CredentialDependency, + ]); + + const result = await repository.findCredentialIdsByDependencyId( + 'externalSecretProvider', + 'provider-1', + ); + + expect(entityManager.find).toHaveBeenCalledWith(CredentialDependency, { + select: ['credentialId'], + where: { dependencyType: 'externalSecretProvider', dependencyId: 'provider-1' }, + }); + expect(result).toEqual(['cred-1', 'cred-2']); + }); + }); + + describe('upsertDependenciesForCredential', () => { + it('deduplicates ids and inserts once with orIgnore', async () => { + const qb = { + insert: jest.fn().mockReturnThis(), + into: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + orIgnore: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue(undefined), + }; + entityManager.createQueryBuilder.mockReturnValue(qb as never); + + await repository.upsertDependenciesForCredential({ + credentialId: 'cred-1', + dependencyType: 'externalSecretProvider', + dependencyIds: ['provider-1', 'provider-1', 'provider-2'], + entityManager, + }); + + expect(qb.values).toHaveBeenCalledWith([ + { + credentialId: 'cred-1', + dependencyType: 'externalSecretProvider', + dependencyId: 'provider-1', + }, + { + credentialId: 'cred-1', + dependencyType: 'externalSecretProvider', + dependencyId: 'provider-2', + }, + ]); + expect(qb.orIgnore).toHaveBeenCalled(); + expect(qb.execute).toHaveBeenCalled(); + }); + + it('returns early when there is nothing to insert', async () => { + await repository.upsertDependenciesForCredential({ + credentialId: 'cred-1', + dependencyType: 'externalSecretProvider', + dependencyIds: [], + entityManager, + }); + + expect(entityManager.createQueryBuilder).not.toHaveBeenCalled(); + }); + }); + + describe('syncDependenciesForCredential', () => { + it('deletes removed ids and inserts new ids', async () => { + entityManager.findBy.mockResolvedValueOnce([ + { dependencyId: 'provider-old' } as CredentialDependency, + { dependencyId: 'provider-keep' } as CredentialDependency, + ]); + + const upsertSpy = jest + .spyOn(repository, 'upsertDependenciesForCredential') + .mockResolvedValue(undefined); + + await repository.syncDependenciesForCredential({ + credentialId: 'cred-1', + dependencyType: 'externalSecretProvider', + dependencyIds: ['provider-keep', 'provider-new'], + entityManager, + }); + + expect(entityManager.delete).toHaveBeenCalledWith(CredentialDependency, { + credentialId: 'cred-1', + dependencyType: 'externalSecretProvider', + dependencyId: In(['provider-old']), + }); + expect(upsertSpy).toHaveBeenCalledWith({ + credentialId: 'cred-1', + dependencyType: 'externalSecretProvider', + dependencyIds: ['provider-new'], + entityManager, + }); + }); + }); + + describe('deleteByDependency', () => { + it('deletes all dependency rows by type and id', async () => { + await repository.deleteByDependency({ + dependencyType: 'externalSecretProvider', + dependencyId: 'provider-1', + entityManager, + }); + + expect(entityManager.delete).toHaveBeenCalledWith(CredentialDependency, { + dependencyType: 'externalSecretProvider', + dependencyId: 'provider-1', + }); + }); + }); + + describe('deleteByDependencies', () => { + it('deletes all dependency rows by type and ids', async () => { + await repository.deleteByDependencies({ + dependencyType: 'externalSecretProvider', + dependencyIds: ['provider-1', 'provider-2'], + entityManager, + }); + + expect(entityManager.delete).toHaveBeenCalledWith(CredentialDependency, { + dependencyType: 'externalSecretProvider', + dependencyId: In(['provider-1', 'provider-2']), + }); + }); + + it('returns early when there is nothing to delete', async () => { + await repository.deleteByDependencies({ + dependencyType: 'externalSecretProvider', + dependencyIds: [], + entityManager, + }); + + expect(entityManager.delete).not.toHaveBeenCalled(); + }); + }); +}); + +describe('addCredentialDependencyExistsFilter', () => { + it('applies the EXISTS dependency filter using andWhere', () => { + const qb = { + andWhere: jest.fn().mockReturnThis(), + }; + const filter = { + dependencyType: 'externalSecretProvider', + dependencyId: 'provider-1', + } as const; + + const result = addCredentialDependencyExistsFilter(qb as never, filter); + + expect(qb.andWhere).toHaveBeenCalledWith( + expect.stringContaining('FROM credential_dependency cd'), + filter, + ); + expect(result).toBe(qb); + }); +}); diff --git a/packages/@n8n/db/src/repositories/__tests__/credentials.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/credentials.repository.test.ts index c233d9f2b77..4729050d06b 100644 --- a/packages/@n8n/db/src/repositories/__tests__/credentials.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/credentials.repository.test.ts @@ -1,5 +1,7 @@ import { Container } from '@n8n/di'; +import type { SelectQueryBuilder } from '@n8n/typeorm'; import { In } from '@n8n/typeorm'; +import { mock } from 'jest-mock-extended'; import { CredentialsEntity } from '../../entities'; import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager'; @@ -53,4 +55,35 @@ describe('CredentialsRepository', () => { expect(callArg!.where).toEqual(expect.objectContaining({ id: In(['id1', 'id2']) })); }); }); + + describe('findAllGlobalCredentials', () => { + it('applies dependency filter through query builder when provided', async () => { + const andWhereSpy = jest.fn().mockReturnThis(); + const getManySpy = jest.fn().mockResolvedValue([]); + const qb = mock>({ + andWhere: andWhereSpy, + getMany: getManySpy, + }); + jest.spyOn(credentialsRepository, 'createQueryBuilder').mockReturnValue(qb); + + await credentialsRepository.findAllGlobalCredentials({ + filters: { + dependency: { + dependencyType: 'externalSecretProvider', + dependencyId: 'provider-1', + }, + }, + }); + + expect(andWhereSpy).toHaveBeenCalledWith( + expect.stringContaining('FROM credential_dependency cd'), + { + dependencyType: 'externalSecretProvider', + dependencyId: 'provider-1', + }, + ); + expect(getManySpy).toHaveBeenCalledTimes(1); + expect(entityManager.find).not.toHaveBeenCalled(); + }); + }); }); 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 fac44e9cd4b..ef6a149cdd8 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 @@ -9,26 +9,25 @@ import { SecretsProviderConnectionRepository } from '../secrets-provider-connect describe('SecretsProviderConnectionRepository', () => { const entityManager = mockEntityManager(SecretsProviderConnection); const repository = Container.get(SecretsProviderConnectionRepository); + const createMockConnection = ( + overrides: Partial = {}, + ): SecretsProviderConnection => { + return mock({ + id: random(1, Number.MAX_SAFE_INTEGER), + providerKey: 'myVault', + type: 'vault', + encryptedSettings: '', + isEnabled: false, + projectAccess: [], + ...overrides, + }); + }; beforeEach(() => { jest.resetAllMocks(); }); 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 = [createMockConnection(), createMockConnection()]; @@ -49,4 +48,49 @@ describe('SecretsProviderConnectionRepository', () => { expect(result).toEqual([]); }); }); + + describe('findIdByProviderKey', () => { + it('returns id when provider exists', async () => { + entityManager.findOne.mockResolvedValueOnce(createMockConnection({ id: 42 })); + + const result = await repository.findIdByProviderKey('myVault'); + + expect(entityManager.findOne).toHaveBeenCalledWith(SecretsProviderConnection, { + select: ['id'], + where: { providerKey: 'myVault' }, + }); + expect(result).toBe('42'); + }); + + it('returns null when provider does not exist', async () => { + entityManager.findOne.mockResolvedValueOnce(null); + + const result = await repository.findIdByProviderKey('missing'); + + expect(result).toBeNull(); + }); + }); + + describe('findIdsByProviderKeys', () => { + it('returns ids as strings for matching providers', async () => { + entityManager.find.mockResolvedValueOnce([ + createMockConnection({ id: 7 }), + createMockConnection({ id: 8 }), + ] as SecretsProviderConnection[]); + + const result = await repository.findIdsByProviderKeys(['vault-a', 'vault-b']); + + expect(entityManager.find).toHaveBeenCalledWith(SecretsProviderConnection, { + select: ['id'], + where: { providerKey: expect.anything() }, + }); + expect(result).toEqual(['7', '8']); + }); + + it('returns empty list for empty input', async () => { + const result = await repository.findIdsByProviderKeys([]); + expect(result).toEqual([]); + expect(entityManager.find).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/@n8n/db/src/repositories/credential-dependency.repository.ts b/packages/@n8n/db/src/repositories/credential-dependency.repository.ts new file mode 100644 index 00000000000..44f9b91ddab --- /dev/null +++ b/packages/@n8n/db/src/repositories/credential-dependency.repository.ts @@ -0,0 +1,161 @@ +import { Service } from '@n8n/di'; +import { + DataSource, + In, + Repository, + type EntityManager, + type SelectQueryBuilder, +} from '@n8n/typeorm'; + +import { + CredentialDependency, + CredentialsEntity, + type CredentialDependencyType, +} from '../entities'; + +export type CredentialDependencyFilter = { + dependencyType: CredentialDependencyType; + dependencyId: string; +}; + +/** + * Apply dependency filter to a credential query. + * Expects outer query builder alias to be "credential". + */ +export function addCredentialDependencyExistsFilter( + qb: SelectQueryBuilder, + filter: CredentialDependencyFilter, +) { + return qb.andWhere( + `EXISTS ( + SELECT 1 + FROM credential_dependency cd + WHERE cd."credentialId" = credential.id + AND cd."dependencyType" = :dependencyType + AND cd."dependencyId" = :dependencyId + )`, + filter, + ); +} + +type DependencyMutationOptions = { + credentialId: string; + dependencyType: CredentialDependencyType; + dependencyIds: string[]; + entityManager?: EntityManager; +}; + +type DeleteByDependencyOptions = { + dependencyType: CredentialDependencyType; + dependencyId: string; + entityManager?: EntityManager; +}; + +type DeleteByDependenciesOptions = { + dependencyType: CredentialDependencyType; + dependencyIds: string[]; + entityManager?: EntityManager; +}; + +@Service() +export class CredentialDependencyRepository extends Repository { + constructor(dataSource: DataSource) { + super(CredentialDependency, dataSource.manager); + } + + async findCredentialIdsByDependencyId( + dependencyType: CredentialDependencyType, + dependencyId: string, + ): Promise { + const results = await this.find({ + select: ['credentialId'], + where: { dependencyType, dependencyId }, + }); + + return results.map((result) => result.credentialId); + } + + async upsertDependenciesForCredential({ + credentialId, + dependencyType, + dependencyIds, + entityManager = this.manager, + }: DependencyMutationOptions): Promise { + if (dependencyIds.length === 0) return; + + const uniqueDependencyIds = Array.from(new Set(dependencyIds)); + + await entityManager + .createQueryBuilder() + .insert() + .into(CredentialDependency) + .values( + uniqueDependencyIds.map((dependencyId) => ({ + credentialId, + dependencyType, + dependencyId, + })), + ) + .orIgnore() + .execute(); + } + + async syncDependenciesForCredential({ + credentialId, + dependencyType, + dependencyIds, + entityManager = this.manager, + }: DependencyMutationOptions): Promise { + const nextIds = new Set(dependencyIds); + + const existing = await entityManager.findBy(CredentialDependency, { + credentialId, + dependencyType, + }); + + const existingIds = new Set(existing.map(({ dependencyId }) => dependencyId)); + const idsToInsert = [...nextIds].filter((id) => !existingIds.has(id)); + const idsToDelete = [...existingIds].filter((id) => !nextIds.has(id)); + + if (idsToDelete.length > 0) { + await entityManager.delete(CredentialDependency, { + credentialId, + dependencyType, + dependencyId: In(idsToDelete), + }); + } + + if (idsToInsert.length > 0) { + await this.upsertDependenciesForCredential({ + credentialId, + dependencyType, + dependencyIds: idsToInsert, + entityManager, + }); + } + } + + async deleteByDependency({ + dependencyType, + dependencyId, + entityManager = this.manager, + }: DeleteByDependencyOptions): Promise { + await entityManager.delete(CredentialDependency, { + dependencyType, + dependencyId, + }); + } + + async deleteByDependencies({ + dependencyType, + dependencyIds, + entityManager = this.manager, + }: DeleteByDependenciesOptions): Promise { + if (dependencyIds.length === 0) return; + + await entityManager.delete(CredentialDependency, { + dependencyType, + dependencyId: In(dependencyIds), + }); + } +} diff --git a/packages/@n8n/db/src/repositories/credentials.repository.ts b/packages/@n8n/db/src/repositories/credentials.repository.ts index 20a00e15e52..ce94ab8b171 100644 --- a/packages/@n8n/db/src/repositories/credentials.repository.ts +++ b/packages/@n8n/db/src/repositories/credentials.repository.ts @@ -3,8 +3,11 @@ import type { Scope } from '@n8n/permissions'; import type { FindManyOptions, SelectQueryBuilder } from '@n8n/typeorm'; import { DataSource, In, Like, Repository } from '@n8n/typeorm'; -import { CredentialsEntity } from '../entities'; -import type { User } from '../entities'; +import { CredentialsEntity, type User } from '../entities'; +import { + addCredentialDependencyExistsFilter, + type CredentialDependencyFilter, +} from './credential-dependency.repository'; import { SharedCredentialsRepository } from './shared-credentials.repository'; import type { ListQuery } from '../entities/types-db'; @@ -185,14 +188,63 @@ export class CredentialsRepository extends Repository { /** * Find all global credentials */ - async findAllGlobalCredentials(includeData = false): Promise { + async findAllGlobalCredentials( + options: { + includeData?: boolean; + filters?: { + dependency?: CredentialDependencyFilter; + }; + } = {}, + ): Promise { + const { includeData = false, filters } = options; + + const dependencyFilter = filters?.dependency; + if (dependencyFilter) { + return await this.findAllGlobalCredentialsByDependencyFilter({ + dependencyFilter, + includeData, + }); + } + const findManyOptions = this.toFindManyOptions({ includeData }); - findManyOptions.where = { ...findManyOptions.where, isGlobal: true }; - return await this.find(findManyOptions); } + private async findAllGlobalCredentialsByDependencyFilter(options: { + dependencyFilter: CredentialDependencyFilter; + includeData?: boolean; + }): Promise { + const { includeData, dependencyFilter } = options; + + const qb = this.createQueryBuilder('credential'); + qb.where('credential.isGlobal = :isGlobal', { isGlobal: true }); + addCredentialDependencyExistsFilter(qb, dependencyFilter); + + const defaultSelect: Array = [ + 'id', + 'name', + 'type', + 'isManaged', + 'createdAt', + 'updatedAt', + 'isGlobal', + 'isResolvable', + 'resolverId', + ]; + const selectColumns = defaultSelect.map((k) => `credential.${k}`); + if (includeData) { + selectColumns.push('credential.data'); + } + + qb.select(selectColumns); + qb.leftJoinAndSelect('credential.shared', 'shared'); + qb.leftJoinAndSelect('shared.project', 'project'); + qb.leftJoinAndSelect('project.projectRelations', 'projectRelations'); + + return await qb.getMany(); + } + /** * Find all credentials that are owned by a personal project. */ @@ -240,6 +292,9 @@ export class CredentialsRepository extends Repository { options: ListQuery.Options & { includeData?: boolean; order?: FindManyOptions['order']; + filters?: { + dependency?: CredentialDependencyFilter; + }; } = {}, ) { const query = this.getManyQueryWithSharingSubquery(user, sharingOptions, options); @@ -277,10 +332,17 @@ export class CredentialsRepository extends Repository { options: ListQuery.Options & { includeData?: boolean; order?: FindManyOptions['order']; + filters?: { + dependency?: CredentialDependencyFilter; + }; } = {}, ): SelectQueryBuilder { const qb = this.createQueryBuilder('credential'); + if (options.filters?.dependency) { + addCredentialDependencyExistsFilter(qb, options.filters.dependency); + } + // Pass projectId from options to sharing options const projectId = typeof options.filter?.projectId === 'string' ? options.filter.projectId : undefined; diff --git a/packages/@n8n/db/src/repositories/index.ts b/packages/@n8n/db/src/repositories/index.ts index 4bf08049a11..fd1a77d954b 100644 --- a/packages/@n8n/db/src/repositories/index.ts +++ b/packages/@n8n/db/src/repositories/index.ts @@ -5,6 +5,7 @@ export { AuthIdentityRepository } from './auth-identity.repository'; export { AuthProviderSyncHistoryRepository } from './auth-provider-sync-history.repository'; export { BinaryDataRepository } from './binary-data.repository'; export { CredentialsRepository } from './credentials.repository'; +export { CredentialDependencyRepository } from './credential-dependency.repository'; export { ExecutionAnnotationRepository } from './execution-annotation.repository'; export { ExecutionDataRepository } from './execution-data.repository'; export { ExecutionMetadataRepository } from './execution-metadata.repository'; diff --git a/packages/@n8n/db/src/repositories/project-secrets-provider-access.repository.ee.ts b/packages/@n8n/db/src/repositories/project-secrets-provider-access.repository.ee.ts index 188db24cb87..df2fe2ccfcf 100644 --- a/packages/@n8n/db/src/repositories/project-secrets-provider-access.repository.ee.ts +++ b/packages/@n8n/db/src/repositories/project-secrets-provider-access.repository.ee.ts @@ -1,5 +1,5 @@ import { Service } from '@n8n/di'; -import { DataSource, In, Repository } from '@n8n/typeorm'; +import { DataSource, EntityManager, In, Repository } from '@n8n/typeorm'; import { ProjectSecretsProviderAccess } from '../entities'; import type { SecretsProviderAccessRole } from '../entities'; @@ -25,8 +25,11 @@ export class ProjectSecretsProviderAccessRepository extends Repository { - await this.delete({ secretsProviderConnectionId }); + async deleteByConnectionId( + secretsProviderConnectionId: number, + entityManager: EntityManager = this.manager, + ): Promise { + await entityManager.delete(this.target, { secretsProviderConnectionId }); } async updateProjectAccess( 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 d0ac2bba987..94d7fcafd6a 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 @@ -1,5 +1,5 @@ import { Service } from '@n8n/di'; -import { Brackets, DataSource, Repository } from '@n8n/typeorm'; +import { Brackets, DataSource, In, Repository } from '@n8n/typeorm'; import { SecretsProviderConnection, SharedCredentials } from '../entities'; @@ -13,6 +13,26 @@ export class SecretsProviderConnectionRepository extends Repository { + const connection = await this.findOne({ + select: ['id'], + where: { providerKey }, + }); + + return connection ? connection.id.toString() : null; + } + + async findIdsByProviderKeys(providerKeys: string[]): Promise { + if (providerKeys.length === 0) return []; + + const connections = await this.find({ + select: ['id'], + where: { providerKey: In(providerKeys) }, + }); + + return connections.map(({ id }) => id.toString()); + } + async hasGlobalProvider(providerKey: string): Promise { const count = await this.manager .createQueryBuilder(SecretsProviderConnection, 'connection') @@ -210,21 +230,4 @@ export class SecretsProviderConnectionRepository extends Repository { - const connection = await this.findByProviderKeyAndProjectId(providerKey, projectId); - - if (!connection) { - return null; - } - - return await this.remove(connection); - } } diff --git a/packages/cli/src/credentials/__tests__/credential-dependency.service.test.ts b/packages/cli/src/credentials/__tests__/credential-dependency.service.test.ts new file mode 100644 index 00000000000..7f914d98c98 --- /dev/null +++ b/packages/cli/src/credentials/__tests__/credential-dependency.service.test.ts @@ -0,0 +1,217 @@ +import type { CredentialDependencyRepository, SecretsProviderConnectionRepository } from '@n8n/db'; +import { In } from '@n8n/typeorm'; +import type { EntityManager } from '@n8n/typeorm'; +import { mock } from 'jest-mock-extended'; + +import { + CredentialDependencyService, + EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, +} from '@/credentials/credential-dependency.service'; + +describe('CredentialDependencyService', () => { + const credentialDependencyRepository = mock(); + const secretsProviderConnectionRepository = mock(); + const service = new CredentialDependencyService( + credentialDependencyRepository, + secretsProviderConnectionRepository, + ); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('resolveExternalSecretsStoreDependencyFilter', () => { + it('returns dependency filter when provider exists', async () => { + secretsProviderConnectionRepository.findIdByProviderKey.mockResolvedValue('42'); + + const result = await service.resolveExternalSecretsStoreDependencyFilter('vault'); + + expect(secretsProviderConnectionRepository.findIdByProviderKey).toHaveBeenCalledWith('vault'); + expect(result).toEqual({ + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyId: '42', + }); + }); + + it('returns undefined when provider does not exist', async () => { + secretsProviderConnectionRepository.findIdByProviderKey.mockResolvedValue(null); + + const result = await service.resolveExternalSecretsStoreDependencyFilter('missing'); + + expect(result).toBeUndefined(); + }); + }); + + describe('upsertExternalSecretProviderDependenciesForCredential', () => { + it('resolves provider ids and upserts dependencies', async () => { + secretsProviderConnectionRepository.findIdsByProviderKeys.mockResolvedValue(['7', '8']); + const entityManager = mock(); + + await service.upsertExternalSecretProviderDependenciesForCredential({ + credentialId: 'cred-1', + decryptedCredentialData: { + apiKey: '={{ $secrets.vault.apiKey }}', + token: '={{ $secrets["aws-secrets-manager"].token }}', + }, + entityManager, + }); + + expect(secretsProviderConnectionRepository.findIdsByProviderKeys).toHaveBeenCalledWith([ + 'vault', + 'aws-secrets-manager', + ]); + expect(credentialDependencyRepository.upsertDependenciesForCredential).toHaveBeenCalledWith({ + credentialId: 'cred-1', + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyIds: ['7', '8'], + entityManager, + }); + }); + + it('handles credential data without external providers', async () => { + secretsProviderConnectionRepository.findIdsByProviderKeys.mockResolvedValue([]); + const entityManager = mock(); + + await service.upsertExternalSecretProviderDependenciesForCredential({ + credentialId: 'cred-1', + decryptedCredentialData: { apiKey: 'plain-value' }, + entityManager, + }); + + expect(secretsProviderConnectionRepository.findIdsByProviderKeys).toHaveBeenCalledWith([]); + expect(credentialDependencyRepository.upsertDependenciesForCredential).toHaveBeenCalledWith({ + credentialId: 'cred-1', + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyIds: [], + entityManager, + }); + }); + }); + + describe('syncExternalSecretProviderDependenciesForCredential', () => { + it('resolves provider ids and syncs dependencies', async () => { + secretsProviderConnectionRepository.findIdsByProviderKeys.mockResolvedValue(['7', '8']); + const entityManager = mock(); + + await service.syncExternalSecretProviderDependenciesForCredential({ + credentialId: 'cred-1', + decryptedCredentialData: { + apiKey: '={{ $secrets.vault.apiKey }}', + token: '={{ $secrets["aws-secrets-manager"].token }}', + }, + entityManager, + }); + + expect(secretsProviderConnectionRepository.findIdsByProviderKeys).toHaveBeenCalledWith([ + 'vault', + 'aws-secrets-manager', + ]); + expect(credentialDependencyRepository.syncDependenciesForCredential).toHaveBeenCalledWith({ + credentialId: 'cred-1', + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyIds: ['7', '8'], + entityManager, + }); + }); + + it('handles credential data without external providers', async () => { + secretsProviderConnectionRepository.findIdsByProviderKeys.mockResolvedValue([]); + const entityManager = mock(); + + await service.syncExternalSecretProviderDependenciesForCredential({ + credentialId: 'cred-1', + decryptedCredentialData: { apiKey: 'plain-value' }, + entityManager, + }); + + expect(secretsProviderConnectionRepository.findIdsByProviderKeys).toHaveBeenCalledWith([]); + expect(credentialDependencyRepository.syncDependenciesForCredential).toHaveBeenCalledWith({ + credentialId: 'cred-1', + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyIds: [], + entityManager, + }); + }); + }); + + describe('deleteDependencyById', () => { + it('deletes through entity manager when provided', async () => { + const entityManager = mock(); + + await service.deleteDependencyById({ + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyId: 'provider-1', + entityManager, + }); + + expect(entityManager.delete).toHaveBeenCalledWith(credentialDependencyRepository.target, { + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyId: 'provider-1', + }); + expect(credentialDependencyRepository.delete).not.toHaveBeenCalled(); + }); + + it('deletes through repository when entity manager is not provided', async () => { + const manager = mock(); + Object.defineProperty(credentialDependencyRepository, 'manager', { + value: manager, + configurable: true, + }); + + await service.deleteDependencyById({ + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyId: 'provider-1', + }); + + expect(manager.delete).toHaveBeenCalledWith(credentialDependencyRepository.target, { + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyId: 'provider-1', + }); + }); + }); + + describe('deleteDependenciesByIds', () => { + it('returns early when dependency ids are empty', async () => { + await service.deleteDependenciesByIds({ + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyIds: [], + }); + + expect(credentialDependencyRepository.delete).not.toHaveBeenCalled(); + }); + + it('deletes through entity manager when provided', async () => { + const entityManager = mock(); + + await service.deleteDependenciesByIds({ + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyIds: ['provider-1', 'provider-2'], + entityManager, + }); + + expect(entityManager.delete).toHaveBeenCalledWith(credentialDependencyRepository.target, { + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyId: In(['provider-1', 'provider-2']), + }); + expect(credentialDependencyRepository.delete).not.toHaveBeenCalled(); + }); + + it('deletes through repository when entity manager is not provided', async () => { + const manager = mock(); + Object.defineProperty(credentialDependencyRepository, 'manager', { + value: manager, + configurable: true, + }); + + await service.deleteDependenciesByIds({ + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyIds: ['provider-1', 'provider-2'], + }); + + expect(manager.delete).toHaveBeenCalledWith(credentialDependencyRepository.target, { + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyId: In(['provider-1', 'provider-2']), + }); + }); + }); +}); diff --git a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts index beacf09c9b6..17c406adc0e 100644 --- a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts @@ -5,26 +5,30 @@ jest.mock('@/generic-helpers', () => ({ import type { LicenseState } from '@n8n/backend-common'; import type { AuthenticatedRequest, + ICredentialsDb, + Project, + SharedCredentials, SharedCredentialsRepository, CredentialsEntity, CredentialsRepository, } from '@n8n/db'; import { GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db'; +import type { Scope } from '@n8n/permissions'; import { mock } from 'jest-mock-extended'; -import { createRawProjectData } from '@/__tests__/project.test-data'; -import type { EventService } from '@/events/event.service'; -import type { CredentialRequest } from '@/requests'; - +import { createNewCredentialsPayload, createdCredentialsWithScopes } from './credentials.test-data'; +import type { CredentialDependencyService } from '../credential-dependency.service'; import type { CredentialsFinderService } from '../credentials-finder.service'; import { CredentialsController } from '../credentials.controller'; import { CredentialsService } from '../credentials.service'; import * as validation from '../validation'; + import * as checkAccess from '@/permissions.ee/check-access'; -import { createNewCredentialsPayload, createdCredentialsWithScopes } from './credentials.test-data'; +import type { CredentialRequest } from '@/requests'; describe('CredentialsController', () => { - const eventService = mock(); + type ControllerEventService = ConstructorParameters[9]; + const eventService = mock(); const sharedCredentialsRepository = mock(); const credentialsFinderService = mock(); const licenseState = mock(); @@ -35,6 +39,7 @@ describe('CredentialsController', () => { // real CredentialsService instance with mocked dependencies const credentialsService = new CredentialsService( credentialsRepository, + mock(), mock(), // sharedCredentialsRepository mock(), // ownershipService mock(), // logger @@ -55,12 +60,16 @@ describe('CredentialsController', () => { // Spy on methods that need to be mocked in tests // This allows us to mock specific behavior while keeping real implementations // for isChangingExternalSecretExpression and validateExternalSecretsPermissions - jest.spyOn(credentialsService, 'decrypt'); - jest.spyOn(credentialsService, 'prepareUpdateData'); - jest.spyOn(credentialsService, 'createEncryptedData'); - jest.spyOn(credentialsService, 'getCredentialScopes'); - jest.spyOn(credentialsService, 'update'); - jest.spyOn(credentialsService, 'createUnmanagedCredential'); + const decryptSpy = jest.spyOn(credentialsService, 'decrypt'); + const createEncryptedDataSpy = jest.spyOn(credentialsService, 'createEncryptedData'); + const getCredentialScopesSpy = jest.spyOn(credentialsService, 'getCredentialScopes'); + const updateSpy = jest.spyOn(credentialsService, 'update'); + const createUnmanagedCredentialSpy = jest.spyOn(credentialsService, 'createUnmanagedCredential'); + const findCredentialOwningProjectSpy = jest.spyOn( + sharedCredentialsRepository, + 'findCredentialOwningProject', + ); + const emitSpy = jest.spyOn(eventService, 'emit'); const credentialsController = new CredentialsController( mock(), @@ -98,17 +107,14 @@ describe('CredentialsController', () => { const createdCredentials = createdCredentialsWithScopes(payloadWithoutData); - const projectOwningCredentialData = createRawProjectData({ + const projectOwningCredentialData = mock({ id: newCredentialsPayload.projectId, + type: 'team', }); - jest - .mocked(credentialsService.createUnmanagedCredential) - .mockResolvedValue(createdCredentials); + createUnmanagedCredentialSpy.mockResolvedValue(createdCredentials); - sharedCredentialsRepository.findCredentialOwningProject.mockResolvedValue( - projectOwningCredentialData, - ); + findCredentialOwningProjectSpy.mockResolvedValue(projectOwningCredentialData); // Act @@ -120,15 +126,12 @@ describe('CredentialsController', () => { // Assert - expect(credentialsService.createUnmanagedCredential).toHaveBeenCalledWith( - newCredentialsPayload, - req.user, - ); - expect(sharedCredentialsRepository.findCredentialOwningProject).toHaveBeenCalledWith( - createdCredentials.id, - ); - expect(eventService.emit).toHaveBeenCalledWith('credentials-created', { - user: expect.objectContaining({ id: req.user.id }), + expect(createUnmanagedCredentialSpy).toHaveBeenCalledWith(newCredentialsPayload, req.user); + expect(findCredentialOwningProjectSpy).toHaveBeenCalledWith(createdCredentials.id); + expect(emitSpy).toHaveBeenCalledTimes(1); + const [eventName, eventPayload] = emitSpy.mock.calls[0]; + expect(eventName).toBe('credentials-created'); + expect(eventPayload).toMatchObject({ credentialId: createdCredentials.id, credentialType: createdCredentials.type, projectId: projectOwningCredentialData.id, @@ -137,6 +140,7 @@ describe('CredentialsController', () => { uiContext: newCredentialsPayload.uiContext, isDynamic: false, }); + expect((eventPayload as { user: { id: string } }).user.id).toBe('123'); expect(newApiKey).toEqual(createdCredentials); }); @@ -156,24 +160,27 @@ describe('CredentialsController', () => { role: 'credential:owner', projectId: 'WHwt9vP3keCUvmB5', credentialsId: credentialId, - } as any, + } as SharedCredentials, ], }); + const getEncryptedCredential = (isResolvable = false): ICredentialsDb => ({ + name: 'Updated Credential', + type: 'apiKey', + data: 'encrypted-data', + id: credentialId, + createdAt: new Date(), + updatedAt: new Date(), + isResolvable, + }); + beforeEach(() => { - jest.mocked(credentialsService.decrypt).mockReturnValue({ apiKey: 'test-key' }); - jest.mocked(credentialsService.createEncryptedData).mockReturnValue({ - name: 'Updated Credential', - type: 'apiKey', - data: 'encrypted-data', - id: 'cred-123', - createdAt: new Date(), - updatedAt: new Date(), - isResolvable: false, - } as any); - jest - .mocked(credentialsService.getCredentialScopes) - .mockResolvedValue(['credential:read', 'credential:update'] as any); + decryptSpy.mockReturnValue({ apiKey: 'test-key' }); + createEncryptedDataSpy.mockReturnValue(getEncryptedCredential()); + getCredentialScopesSpy.mockResolvedValue([ + 'credential:read' as Scope, + 'credential:update' as Scope, + ]); }); it('should not allow owner to set isGlobal to true if not licensed', async () => { @@ -199,7 +206,7 @@ describe('CredentialsController', () => { ); // ASSERT - expect(credentialsService.update).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); }); it('should allow owner to set isGlobal to true if licensed', async () => { @@ -218,7 +225,7 @@ describe('CredentialsController', () => { licenseState.isSharingLicensed.mockReturnValue(true); credentialsFinderService.findCredentialForUser.mockResolvedValue(existingCredential); - jest.mocked(credentialsService.update).mockResolvedValue({ + updateSpy.mockResolvedValue({ ...existingCredential, name: 'Updated Credential', isGlobal: true, @@ -228,13 +235,14 @@ describe('CredentialsController', () => { await credentialsController.updateCredentials(ownerReq); // ASSERT - expect(credentialsService.update).toHaveBeenCalledWith( + expect(updateSpy).toHaveBeenCalledWith( credentialId, expect.objectContaining({ isGlobal: true, }), + expect.any(Object), ); - expect(eventService.emit).toHaveBeenCalledWith('credentials-updated', { + expect(emitSpy).toHaveBeenCalledWith('credentials-updated', { user: ownerReq.user, credentialType: existingCredential.type, credentialId: existingCredential.id, @@ -262,7 +270,7 @@ describe('CredentialsController', () => { licenseState.isSharingLicensed.mockReturnValue(true); credentialsFinderService.findCredentialForUser.mockResolvedValue(globalCredential); - jest.mocked(credentialsService.update).mockResolvedValue({ + updateSpy.mockResolvedValue({ ...globalCredential, isGlobal: false, }); @@ -271,11 +279,12 @@ describe('CredentialsController', () => { await credentialsController.updateCredentials(ownerReq); // ASSERT - expect(credentialsService.update).toHaveBeenCalledWith( + expect(updateSpy).toHaveBeenCalledWith( credentialId, expect.objectContaining({ isGlobal: false, }), + expect.any(Object), ); }); @@ -302,7 +311,7 @@ describe('CredentialsController', () => { ); // ASSERT - expect(credentialsService.update).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); }); it('should prevent non-owner from changing isGlobal to true', async () => { @@ -331,7 +340,7 @@ describe('CredentialsController', () => { ); // ASSERT - expect(credentialsService.update).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); }); it('should update credential without changing isGlobal when not provided', async () => { @@ -348,7 +357,7 @@ describe('CredentialsController', () => { } as unknown as CredentialRequest.Update; credentialsFinderService.findCredentialForUser.mockResolvedValue(existingCredential); - jest.mocked(credentialsService.update).mockResolvedValue({ + updateSpy.mockResolvedValue({ ...existingCredential, name: 'Updated Credential', }); @@ -358,12 +367,9 @@ describe('CredentialsController', () => { // ASSERT // Should not include isGlobal in update when not provided - expect(credentialsService.update).toHaveBeenCalledWith( - credentialId, - expect.not.objectContaining({ - isGlobal: expect.anything(), - }), - ); + expect(updateSpy).toHaveBeenCalledWith(credentialId, expect.any(Object), expect.any(Object)); + const updatePayload = updateSpy.mock.calls[0][1]; + expect(updatePayload).not.toHaveProperty('isGlobal'); }); it('should update isResolvable when provided', async () => { @@ -387,16 +393,8 @@ describe('CredentialsController', () => { credentialsFinderService.findCredentialForUser.mockResolvedValue( existingCredentialWithResolvable, ); - jest.mocked(credentialsService.createEncryptedData).mockReturnValue({ - name: 'Updated Credential', - type: 'apiKey', - data: 'encrypted-data', - id: 'cred-123', - createdAt: new Date(), - updatedAt: new Date(), - isResolvable: true, - } as any); - jest.mocked(credentialsService.update).mockResolvedValue({ + createEncryptedDataSpy.mockReturnValue(getEncryptedCredential(true)); + updateSpy.mockResolvedValue({ ...existingCredentialWithResolvable, name: 'Updated Credential', isResolvable: true, @@ -406,11 +404,12 @@ describe('CredentialsController', () => { await credentialsController.updateCredentials(ownerReq); // ASSERT - expect(credentialsService.update).toHaveBeenCalledWith( + expect(updateSpy).toHaveBeenCalledWith( credentialId, expect.objectContaining({ isResolvable: true, }), + expect.any(Object), ); }); @@ -435,16 +434,8 @@ describe('CredentialsController', () => { credentialsFinderService.findCredentialForUser.mockResolvedValue( existingCredentialWithResolvable, ); - jest.mocked(credentialsService.createEncryptedData).mockReturnValue({ - name: 'Updated Credential', - type: 'apiKey', - data: 'encrypted-data', - id: 'cred-123', - createdAt: new Date(), - updatedAt: new Date(), - isResolvable: true, - } as any); - jest.mocked(credentialsService.update).mockResolvedValue({ + createEncryptedDataSpy.mockReturnValue(getEncryptedCredential(true)); + updateSpy.mockResolvedValue({ ...existingCredentialWithResolvable, name: 'Updated Credential', }); @@ -453,11 +444,12 @@ describe('CredentialsController', () => { await credentialsController.updateCredentials(ownerReq); // ASSERT - expect(credentialsService.update).toHaveBeenCalledWith( + expect(updateSpy).toHaveBeenCalledWith( credentialId, expect.objectContaining({ isResolvable: true, // Should keep the existing value }), + expect.any(Object), ); }); @@ -481,7 +473,7 @@ describe('CredentialsController', () => { existingCredentialWithSecret, ); // Mock setup: existing credential already has a secret expression - jest.mocked(credentialsService.decrypt).mockReturnValue({ apiKey: '$secrets.oldKey' }); + decryptSpy.mockReturnValue({ apiKey: '$secrets.oldKey' }); await expect(credentialsController.updateCredentials(memberReq)).rejects.toThrow( 'Lacking permissions to reference external secrets in credentials', @@ -492,7 +484,7 @@ describe('CredentialsController', () => { dataToSave: memberReq.body.data, decryptedExistingData: { apiKey: '$secrets.oldKey' }, }); - expect(credentialsService.update).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); }); it('should throw error when adding new external secret expression without permission', async () => { @@ -511,7 +503,7 @@ describe('CredentialsController', () => { // Mock setup: existing credential has no external secret yet credentialsFinderService.findCredentialForUser.mockResolvedValue(existingCredential); - jest.mocked(credentialsService.decrypt).mockReturnValue({ apiKey: 'regular-key' }); + decryptSpy.mockReturnValue({ apiKey: 'regular-key' }); await expect(credentialsController.updateCredentials(memberReq)).rejects.toThrow( 'Lacking permissions to reference external secrets in credentials', @@ -522,7 +514,7 @@ describe('CredentialsController', () => { dataToSave: memberReq.body.data, decryptedExistingData: { apiKey: 'regular-key' }, }); - expect(credentialsService.update).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index 739a5e484e8..d01efd79036 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -16,7 +16,11 @@ import { type ICredentialDataDecryptedObject, type ICredentialType, } from 'n8n-workflow'; + +import { mockExistingCredential } from './credentials.test-data'; + import type { CredentialTypes } from '@/credential-types'; +import type { CredentialDependencyService } from '@/credentials/credential-dependency.service'; import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsService } from '@/credentials/credentials.service'; import * as validation from '@/credentials/validation'; @@ -30,8 +34,6 @@ import type { OwnershipService } from '@/services/ownership.service'; import type { ProjectService } from '@/services/project.service.ee'; import type { RoleService } from '@/services/role.service'; -import { mockExistingCredential } from './credentials.test-data'; - const ownerUser = mock({ id: 'owner-id', role: GLOBAL_OWNER_ROLE }); const memberUser = mock({ id: 'member-id', role: GLOBAL_MEMBER_ROLE }); @@ -57,6 +59,7 @@ describe('CredentialsService', () => { const errorReporter = mock(); const credentialTypes = mock(); const credentialsRepository = mock(); + const credentialDependencyService = mock(); const sharedCredentialsRepository = mock(); const ownershipService = mock(); const logger = mock(); @@ -73,6 +76,7 @@ describe('CredentialsService', () => { const service = new CredentialsService( credentialsRepository, + credentialDependencyService, sharedCredentialsRepository, ownershipService, logger, @@ -92,6 +96,13 @@ describe('CredentialsService', () => { beforeEach(() => { jest.resetAllMocks(); + credentialDependencyService.resolveExternalSecretsStoreDependencyFilter.mockResolvedValue( + undefined, + ); + credentialDependencyService.syncExternalSecretProviderDependenciesForCredential.mockResolvedValue( + undefined, + ); + ownershipService.addOwnedByAndSharedWith.mockImplementation((credential: any) => credential); // Mock the subquery method used by member users and admin users with onlySharedWithMe credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({ credentials: [], @@ -956,7 +967,9 @@ describe('CredentialsService', () => { projectRoles: expect.any(Array), credentialRoles: expect.any(Array), }), - {}, + expect.objectContaining({ + filters: { dependency: undefined }, + }), ); }); @@ -1136,7 +1149,9 @@ describe('CredentialsService', () => { expect.objectContaining({ onlySharedWithMe: true, }), - {}, + expect.objectContaining({ + filters: { dependency: undefined }, + }), ); expect(sharedCredentialsRepository.getAllRelationsForCredentials).toHaveBeenCalledWith([ 'cred-1', @@ -1191,7 +1206,10 @@ describe('CredentialsService', () => { // ASSERT expect(credentialsRepository.findMany).toHaveBeenCalled(); - expect(credentialsRepository.findAllGlobalCredentials).toHaveBeenCalledWith(false); + expect(credentialsRepository.findAllGlobalCredentials).toHaveBeenCalledWith({ + includeData: false, + filters: { dependency: undefined }, + }); expect(result).toHaveLength(2); expect(result).toEqual( expect.arrayContaining([ @@ -1216,7 +1234,10 @@ describe('CredentialsService', () => { // ASSERT expect(credentialsRepository.getManyAndCountWithSharingSubquery).toHaveBeenCalled(); - expect(credentialsRepository.findAllGlobalCredentials).toHaveBeenCalledWith(false); + expect(credentialsRepository.findAllGlobalCredentials).toHaveBeenCalledWith({ + includeData: false, + filters: { dependency: undefined }, + }); expect(result).toHaveLength(2); expect(result).toEqual( expect.arrayContaining([ @@ -1272,7 +1293,10 @@ describe('CredentialsService', () => { }); // ASSERT - expect(credentialsRepository.findAllGlobalCredentials).toHaveBeenCalledWith(true); + expect(credentialsRepository.findAllGlobalCredentials).toHaveBeenCalledWith({ + includeData: true, + filters: { dependency: undefined }, + }); }); }); @@ -1312,68 +1336,62 @@ describe('CredentialsService', () => { }); describe('with externalSecretsStore', () => { - const createCredentialWithEncryptedData = (id: string, apiKey: string) => - mock({ - id, - data: service.createEncryptedData({ ...regularCredential, data: { apiKey } }).data, - }); - - it('should filter credentials by external secrets store using dot notation', async () => { + it('should resolve dependency filter and pass it to repository query', async () => { // ARRANGE - const credentialWithExternalSecret1 = createCredentialWithEncryptedData( - 'cred-with-external-secret-1', - '{{ $secrets.myProvider.apiKey }}', - ); - const credentialWithExternalSecret2 = createCredentialWithEncryptedData( - 'cred-with-external-secret-2', - '{{ $secrets.anotherProvider.apiKey }}', + ownershipService.addOwnedByAndSharedWith.mockImplementation( + (credential: any) => credential, ); + const credentialWithExternalSecret1 = { + id: 'cred-with-external-secret-1', + name: 'Credential with secret 1', + type: 'apiKey', + isGlobal: false, + shared: [], + } as Partial as CredentialsEntity; + credentialDependencyService.resolveExternalSecretsStoreDependencyFilter.mockResolvedValue({ + dependencyType: 'externalSecretProvider', + dependencyId: '123', + }); credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({ - credentials: [ - regularCredential, - credentialWithExternalSecret1, - credentialWithExternalSecret2, - ], - count: 3, + credentials: [credentialWithExternalSecret1], + count: 1, }); // ACT const result = await service.getMany(memberUser, { - externalSecretsStore: 'myProvider', + filters: { externalSecretsStore: 'myProvider' }, + listQueryOptions: { select: { id: true } } as any, }); // ASSERT expect(result).toHaveLength(1); expect(result[0].id).toBe(credentialWithExternalSecret1.id); + expect(credentialsRepository.getManyAndCountWithSharingSubquery).toHaveBeenCalledWith( + memberUser, + expect.any(Object), + { + select: { id: true }, + filters: { + dependency: { + dependencyType: 'externalSecretProvider', + dependencyId: '123', + }, + }, + }, + ); }); - it('should filter credentials by external secrets store using square bracket notation', async () => { - // ARRANGE - const credentialWithExternalSecret1 = createCredentialWithEncryptedData( - 'cred-with-external-secret-1', - '{{ $secrets["myProvider"]["apiKey"] }}', + it('should return early when no credential dependencies match the external secrets store', async () => { + credentialDependencyService.resolveExternalSecretsStoreDependencyFilter.mockResolvedValue( + undefined, ); - const credentialWithExternalSecret2 = createCredentialWithEncryptedData( - 'cred-with-external-secret-2', - '{{ $secrets["anotherProvider"]["apiKey"] }}', - ); - credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({ - credentials: [ - regularCredential, - credentialWithExternalSecret1, - credentialWithExternalSecret2, - ], - count: 3, - }); - // ACT const result = await service.getMany(memberUser, { - externalSecretsStore: 'myProvider', + filters: { externalSecretsStore: 'myProvider' }, }); - // ASSERT - expect(result).toHaveLength(1); - expect(result[0].id).toBe(credentialWithExternalSecret1.id); + expect(result).toEqual([]); + expect(credentialsRepository.getManyAndCountWithSharingSubquery).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/cli/src/credentials/__tests__/external-secrets.utils.test.ts b/packages/cli/src/credentials/__tests__/external-secrets.utils.test.ts new file mode 100644 index 00000000000..17572978c2d --- /dev/null +++ b/packages/cli/src/credentials/__tests__/external-secrets.utils.test.ts @@ -0,0 +1,96 @@ +import { + extractProviderKeysFromCredentialData, + extractProviderKeysFromExpression, + getExternalSecretExpressionPaths, +} from '../external-secrets.utils'; + +describe('External secrets utils', () => { + describe('extractProviderKeysFromExpression', () => { + it('extracts single provider from dot notation', () => { + expect(extractProviderKeysFromExpression('={{ $secrets.vault.myKey }}')).toEqual(['vault']); + }); + + it('extracts single provider from bracket notation', () => { + expect(extractProviderKeysFromExpression("={{ $secrets['aws']['secret'] }}")).toEqual([ + 'aws', + ]); + }); + + it('extracts multiple providers from same expression', () => { + const result = extractProviderKeysFromExpression( + '={{ $secrets.vault.myKey + ":" + $secrets.aws.otherKey }}', + ); + expect(result.sort()).toEqual(['aws', 'vault']); + }); + + it('deduplicates repeated provider keys', () => { + expect( + extractProviderKeysFromExpression('={{ $secrets.vault.key1 + $secrets.vault.key2 }}'), + ).toEqual(['vault']); + }); + + it('does not extract partial provider keys from malformed dot notation', () => { + expect( + extractProviderKeysFromExpression( + '={{ $secrets.vault_invalid.key + $secrets.aws.secret }}', + ), + ).toEqual(['aws']); + }); + + it('returns empty array when no $secrets references found', () => { + expect(extractProviderKeysFromExpression('={{ $variables.myVar }}')).toEqual([]); + }); + + it('returns empty array for plain text', () => { + expect(extractProviderKeysFromExpression('some plain text')).toEqual([]); + }); + + it('returns empty array when $secrets is not inside expression braces', () => { + expect(extractProviderKeysFromExpression('$secrets.vault.key')).toEqual([]); + expect( + extractProviderKeysFromExpression('text with $secrets.vault.key but no braces'), + ).toEqual([]); + }); + + it('only extracts providers from inside expression braces', () => { + expect( + extractProviderKeysFromExpression('$secrets.vault.key and {{ $secrets.aws.secret }}'), + ).toEqual(['aws']); + }); + + it('extracts providers from multiple expression blocks', () => { + const expression = 'hello {{ $secrets.vault.key }} world {{ $secrets.aws.secret }}'; + const result = extractProviderKeysFromExpression(expression); + expect(result.sort()).toEqual(['aws', 'vault']); + }); + }); + + describe('getExternalSecretExpressionPaths', () => { + it('returns all paths that contain external secret expressions', () => { + const data = { + a: 'plain', + b: '={{ $secrets.vault.key }}', + nested: { + c: "={{ $secrets['aws']['token'] }}", + }, + arr: [{ d: '={{ $secrets.azure.secret }}' }], + }; + + expect(getExternalSecretExpressionPaths(data).sort()).toEqual(['arr[0].d', 'b', 'nested.c']); + }); + }); + + describe('extractProviderKeysFromCredentialData', () => { + it('extracts unique provider keys across nested credential data', () => { + const data = { + apiKey: '={{ $secrets.vault.key1 }}', + nested: { + token: "={{ $secrets['aws']['token'] }}", + duplicate: '={{ $secrets.vault.key2 }}', + }, + }; + + expect([...extractProviderKeysFromCredentialData(data)].sort()).toEqual(['aws', 'vault']); + }); + }); +}); diff --git a/packages/cli/src/credentials/__tests__/validation.test.ts b/packages/cli/src/credentials/__tests__/validation.test.ts index 11fb90c22a0..07a91ae1ffc 100644 --- a/packages/cli/src/credentials/__tests__/validation.test.ts +++ b/packages/cli/src/credentials/__tests__/validation.test.ts @@ -1,15 +1,13 @@ import { GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE, type User } from '@n8n/db'; import { mock } from 'jest-mock-extended'; -import type { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee'; -import * as checkAccess from '@/permissions.ee/check-access'; - import { validateExternalSecretsPermissions, isChangingExternalSecretExpression, validateAccessToReferencedSecretProviders, - extractProviderKeys, } from '../validation'; +import type { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee'; +import * as checkAccess from '@/permissions.ee/check-access'; const ownerUser = mock({ id: 'owner-id', role: GLOBAL_OWNER_ROLE }); const memberUser = mock({ id: 'member-id', role: GLOBAL_MEMBER_ROLE }); @@ -18,54 +16,6 @@ describe('Credentials Validation', () => { const projectId = 'project-id'; const errorMessage = 'Lacking permissions to reference external secrets in credentials'; - describe('extractProviderKeys', () => { - it('should extract single provider from dot notation', () => { - expect(extractProviderKeys('={{ $secrets.vault.myKey }}')).toEqual(['vault']); - }); - - it('should extract single provider from bracket notation', () => { - expect(extractProviderKeys("={{ $secrets['aws']['secret'] }}")).toEqual(['aws']); - }); - - it('should extract multiple providers from same expression', () => { - const result = extractProviderKeys( - '={{ $secrets.vault.myKey + ":" + $secrets.aws.otherKey }}', - ); - expect(result.sort()).toEqual(['aws', 'vault']); - }); - - it('should deduplicate repeated provider keys', () => { - expect(extractProviderKeys('={{ $secrets.vault.key1 + $secrets.vault.key2 }}')).toEqual([ - 'vault', - ]); - }); - - it('should return empty array when no $secrets references found', () => { - expect(extractProviderKeys('={{ $variables.myVar }}')).toEqual([]); - }); - - it('should return empty array for plain text', () => { - expect(extractProviderKeys('some plain text')).toEqual([]); - }); - - it('should return empty array when $secrets is not inside expression braces', () => { - expect(extractProviderKeys('$secrets.vault.key')).toEqual([]); - expect(extractProviderKeys('text with $secrets.vault.key but no braces')).toEqual([]); - }); - - it('should only extract providers from inside expression braces', () => { - expect(extractProviderKeys('$secrets.vault.key and {{ $secrets.aws.secret }}')).toEqual([ - 'aws', - ]); - }); - - it('should extract providers from multiple expression blocks', () => { - const expression = 'hello {{ $secrets.vault.key }} world {{ $secrets.aws.secret }}'; - const result = extractProviderKeys(expression); - expect(result.sort()).toEqual(['aws', 'vault']); - }); - }); - describe('validateExternalSecretsPermissions', () => { beforeEach(() => { jest.restoreAllMocks(); diff --git a/packages/cli/src/credentials/credential-dependency.service.ts b/packages/cli/src/credentials/credential-dependency.service.ts new file mode 100644 index 00000000000..1d56c7a3d63 --- /dev/null +++ b/packages/cli/src/credentials/credential-dependency.service.ts @@ -0,0 +1,115 @@ +import type { CredentialDependencyType } from '@n8n/db'; +import { CredentialDependencyRepository, SecretsProviderConnectionRepository } from '@n8n/db'; +import { Service } from '@n8n/di'; +// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import +import { In, type EntityManager } from '@n8n/typeorm'; +import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; + +import { extractProviderKeysFromCredentialData } from './external-secrets.utils'; + +export const EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE = 'externalSecretProvider' as const; + +export type CredentialDependencyFilter = { + dependencyType: CredentialDependencyType; + dependencyId: string; +}; + +@Service() +export class CredentialDependencyService { + constructor( + private readonly credentialDependencyRepository: CredentialDependencyRepository, + private readonly secretsProviderConnectionRepository: SecretsProviderConnectionRepository, + ) {} + + async resolveExternalSecretsStoreDependencyFilter( + externalSecretsStoreProviderKey: string, + ): Promise { + const providerId = await this.secretsProviderConnectionRepository.findIdByProviderKey( + externalSecretsStoreProviderKey, + ); + + if (providerId === null) return undefined; + + return { + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyId: providerId, + }; + } + + private async resolveProviderIdsFromCredentialData( + decryptedCredentialData: ICredentialDataDecryptedObject, + ): Promise { + const providerKeys = [...extractProviderKeysFromCredentialData(decryptedCredentialData)]; + return await this.secretsProviderConnectionRepository.findIdsByProviderKeys(providerKeys); + } + + async upsertExternalSecretProviderDependenciesForCredential({ + credentialId, + decryptedCredentialData, + entityManager, + }: { + credentialId: string; + decryptedCredentialData: ICredentialDataDecryptedObject; + entityManager: EntityManager; + }): Promise { + const dependencyIds = await this.resolveProviderIdsFromCredentialData(decryptedCredentialData); + await this.credentialDependencyRepository.upsertDependenciesForCredential({ + credentialId, + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyIds, + entityManager, + }); + } + + async syncExternalSecretProviderDependenciesForCredential({ + credentialId, + decryptedCredentialData, + entityManager, + }: { + credentialId: string; + decryptedCredentialData: ICredentialDataDecryptedObject; + entityManager: EntityManager; + }): Promise { + const dependencyIds = await this.resolveProviderIdsFromCredentialData(decryptedCredentialData); + await this.credentialDependencyRepository.syncDependenciesForCredential({ + credentialId, + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyIds, + entityManager, + }); + } + + async deleteDependencyById({ + dependencyType, + dependencyId, + entityManager, + }: { + dependencyType: CredentialDependencyType; + dependencyId: string; + entityManager?: EntityManager; + }): Promise { + const manager = entityManager ?? this.credentialDependencyRepository.manager; + await manager.delete(this.credentialDependencyRepository.target, { + dependencyType, + dependencyId, + }); + } + + async deleteDependenciesByIds({ + dependencyType, + dependencyIds, + entityManager, + }: { + dependencyType: CredentialDependencyType; + dependencyIds: string[]; + entityManager?: EntityManager; + }): Promise { + if (dependencyIds.length === 0) return; + + const manager = entityManager ?? this.credentialDependencyRepository.manager; + await manager.delete(this.credentialDependencyRepository.target, { + dependencyType, + dependencyId: In(dependencyIds), + }); + } +} diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index a805b73684b..db558bcf2bc 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -75,7 +75,9 @@ export class CredentialsController { includeData: query.includeData, onlySharedWithMe: query.onlySharedWithMe, includeGlobal: query.includeGlobal, - externalSecretsStore: query.externalSecretsStore, + filters: { + externalSecretsStore: query.externalSecretsStore, + }, }); credentials.forEach((c) => { // @ts-expect-error: This is to emulate the old behavior of removing the shared @@ -263,7 +265,13 @@ export class CredentialsController { } newCredentialData.isResolvable = body.isResolvable ?? credential.isResolvable; - const responseData = await this.credentialsService.update(credentialId, newCredentialData); + const responseData = await this.credentialsService.update( + credentialId, + newCredentialData, + body.data + ? (preparedCredentialData.data as unknown as ICredentialDataDecryptedObject) + : undefined, + ); if (responseData === null) { throw new NotFoundError(`Credential ID "${credentialId}" could not be found to be updated.`); diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 9558533bdb4..a9b0efe9890 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -37,6 +37,16 @@ import { NodeHelpers, } from 'n8n-workflow'; +import { + CredentialDependencyService, + type CredentialDependencyFilter, +} from './credential-dependency.service'; +import { CredentialsFinderService } from './credentials-finder.service'; +import { + validateAccessToReferencedSecretProviders, + validateExternalSecretsPermissions, +} from './validation'; + import { CredentialTypes } from '@/credential-types'; import { createCredentialsFromCredentialsEntity, CredentialsHelper } from '@/credentials-helper'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -53,13 +63,6 @@ import { CredentialsTester } from '@/services/credentials-tester.service'; import { OwnershipService } from '@/services/ownership.service'; import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; -import { getAllKeyPaths } from '@/utils'; - -import { CredentialsFinderService } from './credentials-finder.service'; -import { - validateAccessToReferencedSecretProviders, - validateExternalSecretsPermissions, -} from './validation'; export type CredentialsGetSharedOptions = | { allowGlobalScope: true; globalScope: Scope } @@ -69,10 +72,21 @@ type CreateCredentialOptions = CreateCredentialDto & { isManaged: boolean; }; +type GetManyCredentialsOptions = { + listQueryOptions: ListQuery.Options; + includeGlobal: boolean; + includeData: boolean; + onlySharedWithMe: boolean; + filters?: { + dependency?: CredentialDependencyFilter; + }; +}; + @Service() export class CredentialsService { constructor( private readonly credentialsRepository: CredentialsRepository, + private readonly credentialDependencyService: CredentialDependencyService, private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly ownershipService: OwnershipService, private readonly logger: Logger, @@ -93,9 +107,12 @@ export class CredentialsService { private async addGlobalCredentials( credentials: CredentialsEntity[], includeData: boolean, + dependencyFilter?: CredentialDependencyFilter, ): Promise { - const globalCredentials = - await this.credentialsRepository.findAllGlobalCredentials(includeData); + const globalCredentials = await this.credentialsRepository.findAllGlobalCredentials({ + includeData, + filters: { dependency: dependencyFilter }, + }); // Merge and deduplicate based on credential ID const credentialIds = new Set(credentials.map((c) => c.id)); @@ -107,23 +124,27 @@ export class CredentialsService { async getMany( user: User, options: { - listQueryOptions?: ListQuery.Options & { includeData?: boolean }; + listQueryOptions?: ListQuery.Options; includeScopes?: boolean; includeData: true; onlySharedWithMe?: boolean; includeGlobal?: boolean; - externalSecretsStore?: string; + filters?: { + externalSecretsStore?: string; + }; }, ): Promise>>; async getMany( user: User, options?: { - listQueryOptions?: ListQuery.Options & { includeData?: boolean }; + listQueryOptions?: ListQuery.Options; includeScopes?: boolean; includeData?: boolean; onlySharedWithMe?: boolean; includeGlobal?: boolean; - externalSecretsStore?: string; + filters?: { + externalSecretsStore?: string; + }; }, ): Promise; async getMany( @@ -134,47 +155,54 @@ export class CredentialsService { includeData = false, onlySharedWithMe = false, includeGlobal = false, - externalSecretsStore, + filters = {}, }: { - listQueryOptions?: ListQuery.Options & { includeData?: boolean }; + listQueryOptions?: ListQuery.Options; includeScopes?: boolean; includeData?: boolean; onlySharedWithMe?: boolean; includeGlobal?: boolean; - externalSecretsStore?: string; + filters?: { + externalSecretsStore?: string; + }; } = {}, ): Promise> | CredentialsEntity[]> { + const { externalSecretsStore } = filters; const returnAll = hasGlobalScope(user, 'credential:list'); const isDefaultSelect = !listQueryOptions.select; + const dependencyFilter = externalSecretsStore + ? await this.credentialDependencyService.resolveExternalSecretsStoreDependencyFilter( + externalSecretsStore, + ) + : undefined; + + if (externalSecretsStore && !dependencyFilter) { + return []; + } // Auto-enable includeScopes when includeData is requested if (includeData) { includeScopes = true; - listQueryOptions.includeData = true; } let credentials: CredentialsEntity[]; if (returnAll) { - credentials = await this.getManyForAdminUser( - user, + credentials = await this.getManyForAdminUser(user, { listQueryOptions, includeGlobal, includeData, onlySharedWithMe, - ); + filters: { dependency: dependencyFilter }, + }); } else { - credentials = await this.getManyForMemberUser( - user, + credentials = await this.getManyForMemberUser(user, { listQueryOptions, includeGlobal, includeData, onlySharedWithMe, - ); - } - - if (externalSecretsStore) { - credentials = this.filterByExternalSecretsStore(credentials, externalSecretsStore); + filters: { dependency: dependencyFilter }, + }); } return await this.enrichCredentials( @@ -188,55 +216,37 @@ export class CredentialsService { ); } - private filterByExternalSecretsStore( - credentials: CredentialsEntity[], - externalSecretsStore: string, - ): CredentialsEntity[] { - // matches either dot notation ($secrets.providerKey) or square bracket notation ($secrets['providerKey']) - const providerRegex = /\$secrets(?:\.([A-Za-z0-9_-]+)|\[['"]([^'"]+)['"]\])/g; - credentials = credentials.filter((credential) => { - const decryptedData = this.decrypt(credential, true); - const matchingSecretPaths = getAllKeyPaths(decryptedData, '', [], (value) => { - if (!value.includes('$secrets')) { - return false; - } - - let match: RegExpExecArray | null; - while ((match = providerRegex.exec(value)) !== null) { - const providerKey = match[1] ?? match[2]; - if (providerKey === externalSecretsStore) { - return true; - } - } - - return false; - }); - - return matchingSecretPaths.length > 0; - }); - return credentials; - } - private async getManyForAdminUser( user: User, - listQueryOptions: ListQuery.Options & { includeData?: boolean }, - includeGlobal: boolean, - includeData: boolean, - onlySharedWithMe: boolean, + { + listQueryOptions, + includeGlobal, + includeData, + onlySharedWithMe, + filters, + }: GetManyCredentialsOptions, ): Promise { - // If onlySharedWithMe is requested, use the subquery approach even for admin users - if (onlySharedWithMe) { + const { dependency: dependencyFilter } = filters ?? {}; + + // If onlySharedWithMe or dependency filtering is requested, use subquery approach. + if (onlySharedWithMe || dependencyFilter) { const sharingOptions = { - onlySharedWithMe: true, + ...(onlySharedWithMe ? { onlySharedWithMe: true } : {}), }; const { credentials } = await this.credentialsRepository.getManyAndCountWithSharingSubquery( user, sharingOptions, - listQueryOptions, + { + ...listQueryOptions, + ...(includeData ? { includeData: true } : {}), + filters: { + dependency: dependencyFilter, + }, + }, ); if (includeGlobal) { - return await this.addGlobalCredentials(credentials, includeData); + return await this.addGlobalCredentials(credentials, includeData, dependencyFilter); } return credentials; @@ -244,10 +254,13 @@ export class CredentialsService { await this.applyPersonalProjectFilter(listQueryOptions); - let credentials = await this.credentialsRepository.findMany(listQueryOptions); + let credentials = await this.credentialsRepository.findMany({ + ...listQueryOptions, + ...(includeData ? { includeData: true } : {}), + }); if (includeGlobal) { - credentials = await this.addGlobalCredentials(credentials, includeData); + credentials = await this.addGlobalCredentials(credentials, includeData, dependencyFilter); } return credentials; @@ -255,11 +268,16 @@ export class CredentialsService { private async getManyForMemberUser( user: User, - listQueryOptions: ListQuery.Options & { includeData?: boolean }, - includeGlobal: boolean, - includeData: boolean, - onlySharedWithMe: boolean, + { + listQueryOptions, + includeGlobal, + includeData, + onlySharedWithMe, + filters, + }: GetManyCredentialsOptions, ): Promise { + const { dependency: dependencyFilter } = filters ?? {}; + let isPersonalProject = false; let personalProjectOwnerId: string | null = null; @@ -309,19 +327,23 @@ export class CredentialsService { const { credentials } = await this.credentialsRepository.getManyAndCountWithSharingSubquery( user, sharingOptions, - listQueryOptions, + { + ...listQueryOptions, + ...(includeData ? { includeData: true } : {}), + filters: { + dependency: dependencyFilter, + }, + }, ); if (includeGlobal) { - return await this.addGlobalCredentials(credentials, includeData); + return await this.addGlobalCredentials(credentials, includeData, dependencyFilter); } return credentials; } - private async applyPersonalProjectFilter( - listQueryOptions: ListQuery.Options & { includeData?: boolean }, - ): Promise { + private async applyPersonalProjectFilter(listQueryOptions: ListQuery.Options): Promise { const projectId = typeof listQueryOptions.filter?.projectId === 'string' ? listQueryOptions.filter.projectId @@ -350,7 +372,7 @@ export class CredentialsService { isDefaultSelect: boolean, includeScopes: boolean, includeData: true, - listQueryOptions: ListQuery.Options & { includeData?: boolean }, + listQueryOptions: ListQuery.Options, onlySharedWithMe: boolean, ): Promise>>; private async enrichCredentials( @@ -359,7 +381,7 @@ export class CredentialsService { isDefaultSelect: boolean, includeScopes: boolean, includeData: boolean, - listQueryOptions: ListQuery.Options & { includeData?: boolean }, + listQueryOptions: ListQuery.Options, onlySharedWithMe: boolean, ): Promise; private async enrichCredentials( @@ -368,7 +390,7 @@ export class CredentialsService { isDefaultSelect: boolean, includeScopes: boolean, includeData: boolean, - listQueryOptions: ListQuery.Options & { includeData?: boolean }, + listQueryOptions: ListQuery.Options, onlySharedWithMe: boolean, ): Promise> | CredentialsEntity[]> { if (isDefaultSelect) { @@ -397,7 +419,7 @@ export class CredentialsService { private async populateSharedRelations( credentials: CredentialsEntity[], - listQueryOptions: ListQuery.Options & { includeData?: boolean }, + listQueryOptions: ListQuery.Options, onlySharedWithMe: boolean, ): Promise { const needsRelations = @@ -494,8 +516,9 @@ export class CredentialsService { } async findAllGlobalCredentialIds(includeData: boolean = false): Promise { - const globalCredentials = - await this.credentialsRepository.findAllGlobalCredentials(includeData); + const globalCredentials = await this.credentialsRepository.findAllGlobalCredentials({ + includeData, + }); return globalCredentials; } @@ -656,15 +679,29 @@ export class CredentialsService { } } - async update(credentialId: string, newCredentialData: ICredentialsDb) { + async update( + credentialId: string, + newCredentialData: ICredentialsDb, + decryptedCredentialData?: ICredentialDataDecryptedObject, + ) { await this.externalHooks.run('credentials.update', [newCredentialData]); - // Update the credentials in DB - await this.credentialsRepository.update(credentialId, newCredentialData); + return await this.credentialsRepository.manager.transaction(async (transactionManager) => { + // Update the credentials in DB + await transactionManager.update(CredentialsEntity, credentialId, newCredentialData); - // We sadly get nothing back from "update". Neither if it updated a record - // nor the new value. So query now the updated entry. - return await this.credentialsRepository.findOneBy({ id: credentialId }); + if (decryptedCredentialData) { + await this.credentialDependencyService.syncExternalSecretProviderDependenciesForCredential({ + credentialId, + decryptedCredentialData, + entityManager: transactionManager, + }); + } + + // We sadly get nothing back from "update". Neither if it updated a record + // nor the new value. So query now the updated entry. + return await transactionManager.findOneBy(CredentialsEntity, { id: credentialId }); + }); } async save( @@ -672,6 +709,7 @@ export class CredentialsService { encryptedData: ICredentialsDb, user: User, projectId?: string, + decryptedCredentialData?: ICredentialDataDecryptedObject, ) { // To avoid side effects const newCredential = new CredentialsEntity(); @@ -716,6 +754,16 @@ export class CredentialsService { await transactionManager.save(newSharedCredential); + if (decryptedCredentialData) { + await this.credentialDependencyService.upsertExternalSecretProviderDependenciesForCredential( + { + credentialId: savedCredential.id, + decryptedCredentialData, + entityManager: transactionManager, + }, + ); + } + return savedCredential; }); this.logger.debug('New credential created', { @@ -1143,6 +1191,7 @@ export class CredentialsService { encryptedCredential, user, opts.projectId, + opts.data as ICredentialDataDecryptedObject, ); const scopes = await this.getCredentialScopes(user, credential.id); diff --git a/packages/cli/src/credentials/external-secrets.utils.ts b/packages/cli/src/credentials/external-secrets.utils.ts new file mode 100644 index 00000000000..326e6506137 --- /dev/null +++ b/packages/cli/src/credentials/external-secrets.utils.ts @@ -0,0 +1,76 @@ +import get from 'lodash/get'; +import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; + +import { getAllKeyPaths } from '@/utils'; + +/** + * Regular expression pattern for valid provider keys. + * Keep this in sync with the regex implemented in CreateSecretsProviderConnectionDto. + */ +const PROVIDER_KEY_PATTERN = '[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*'; + +/** + * Checks if a string value contains an external secret expression. + * Detects both dot notation ($secrets.vault.key) and bracket notation ($secrets['vault']['key']). + */ +function containsExternalSecretExpression(value: string): boolean { + const containsExpression = value.includes('{{') && value.includes('}}'); + if (!containsExpression) { + return false; + } + return value.includes('$secrets.') || value.includes('$secrets['); +} + +export function getExternalSecretExpressionPaths(data: unknown): string[] { + return getAllKeyPaths(data, '', [], containsExternalSecretExpression); +} + +/** + * Extracts the provider keys from an expression string. + * Supports both dot notation ($secrets.vault.key) and bracket notation ($secrets['vault']['key']). + * Only extracts provider keys from $secrets references inside {{ }} expression braces. + */ +export function extractProviderKeysFromExpression(expression: string): string[] { + const providerKeys = new Set(); + + const expressionBlocks = expression.matchAll(/\{\{(.*?)\}\}/gs); + + for (const expression of expressionBlocks) { + const expressionContent = expression[1]; // Content inside {{ }} + + // Match all dot notation occurrences: $secrets.providerKey + const dotMatches = expressionContent.matchAll( + new RegExp(`\\$secrets\\.(${PROVIDER_KEY_PATTERN})(?=\\.)`, 'g'), + ); + for (const match of dotMatches) { + providerKeys.add(match[1]); + } + + // Match all bracket notation occurrences: $secrets['providerKey'] or $secrets["providerKey"] + const bracketMatches = expressionContent.matchAll( + new RegExp(`\\$secrets\\[['"](${PROVIDER_KEY_PATTERN})['"]\\]`, 'g'), + ); + for (const match of bracketMatches) { + providerKeys.add(match[1]); + } + } + + return Array.from(providerKeys); +} + +export function extractProviderKeysFromCredentialData( + data: ICredentialDataDecryptedObject, +): ReadonlySet { + const secretPaths = getExternalSecretExpressionPaths(data); + const providerKeys = new Set(); + + for (const path of secretPaths) { + const expressionString = get(data, path); + if (typeof expressionString !== 'string') continue; + for (const providerKey of extractProviderKeysFromExpression(expressionString)) { + providerKeys.add(providerKey); + } + } + + return providerKeys; +} diff --git a/packages/cli/src/credentials/validation.ts b/packages/cli/src/credentials/validation.ts index ec193d0d8bf..2429e18c195 100644 --- a/packages/cli/src/credentials/validation.ts +++ b/packages/cli/src/credentials/validation.ts @@ -2,79 +2,22 @@ import type { User } from '@n8n/db'; import get from 'lodash/get'; import { type ICredentialDataDecryptedObject } from 'n8n-workflow'; +import { + extractProviderKeysFromExpression, + getExternalSecretExpressionPaths, +} from './external-secrets.utils'; + import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import type { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee'; import { userHasScopes } from '@/permissions.ee/check-access'; -import { getAllKeyPaths } from '@/utils'; // #region External Secrets -/** - * Regular expression pattern for valid provider keys. - * Keep this in sync with the regex implemented in CreateSecretsProviderConnectionDto. - */ -const PROVIDER_KEY_PATTERN = '[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*'; - -/** - * Checks if a string value contains an external secret expression. - * Detects both dot notation ($secrets.vault.key) and bracket notation ($secrets['vault']['key']). - */ -export function containsExternalSecretExpression(value: string): boolean { - const containsExpression = value.includes('{{') && value.includes('}}'); - if (!containsExpression) { - return false; - } - return value.includes('$secrets.') || value.includes('$secrets['); -} - -/** - * Extracts the provider keys from an expression string. - * Supports both dot notation ($secrets.vault.key) and bracket notation ($secrets['vault']['key']). - * Only extracts provider keys from $secrets references inside {{ }} expression braces. - * - * @param expression - The expression string containing $secrets reference - * @returns Array of unique provider keys, or empty array if none found - * - * @example - * extractProviderKeys("={{ $secrets.vault.myKey }}") // returns ["vault"] - * extractProviderKeys("={{ $secrets['aws']['secret'] }}") // returns ["aws"] - * extractProviderKeys("={{ $secrets.vault.myKey + ':' + $secrets.aws.otherKey }}") // returns ["vault", "aws"] - * extractProviderKeys("$secrets.vault.key") // returns [] (not inside braces) - */ -export function extractProviderKeys(expression: string): string[] { - const providerKeys = new Set(); - - const expressionBlocks = expression.matchAll(/\{\{(.*?)\}\}/gs); - - for (const expression of expressionBlocks) { - const expressionContent = expression[1]; // Content inside {{ }} - - // Match all dot notation occurrences: $secrets.providerKey - const dotMatches = expressionContent.matchAll( - new RegExp(`\\$secrets\\.(${PROVIDER_KEY_PATTERN})`, 'g'), - ); - for (const match of dotMatches) { - providerKeys.add(match[1]); - } - - // Match all bracket notation occurrences: $secrets['providerKey'] or $secrets["providerKey"] - const bracketMatches = expressionContent.matchAll( - new RegExp(`\\$secrets\\[['"](${PROVIDER_KEY_PATTERN})['"]\\]`, 'g'), - ); - for (const match of bracketMatches) { - providerKeys.add(match[1]); - } - } - - return Array.from(providerKeys); -} - /** * Checks if credential data contains any external secret expressions ($secrets) */ function containsExternalSecrets(data: ICredentialDataDecryptedObject): boolean { - const secretPaths = getAllKeyPaths(data, '', [], containsExternalSecretExpression); - return secretPaths.length > 0; + return getExternalSecretExpressionPaths(data).length > 0; } /** @@ -85,7 +28,7 @@ export function isChangingExternalSecretExpression( existingData: ICredentialDataDecryptedObject, ): boolean { // Find all paths in newData that contain external secret expressions - const newSecretPaths = getAllKeyPaths(newData, '', [], containsExternalSecretExpression); + const newSecretPaths = getExternalSecretExpressionPaths(newData); // Check if any of these paths represent a change from existingData for (const path of newSecretPaths) { @@ -150,7 +93,7 @@ export async function validateAccessToReferencedSecretProviders( externalSecretsProviderAccessCheckService: SecretsProviderAccessCheckService, source: 'create' | 'update' | 'transfer', ) { - const secretPaths = getAllKeyPaths(data, '', [], containsExternalSecretExpression); + const secretPaths = getExternalSecretExpressionPaths(data); if (secretPaths.length === 0) { return; // No external secrets referenced, nothing to check } @@ -161,7 +104,7 @@ export async function validateAccessToReferencedSecretProviders( for (const credentialProperty of secretPaths) { const expressionString = get(data, credentialProperty); if (typeof expressionString === 'string') { - const providerKeys = extractProviderKeys(expressionString); + const providerKeys = extractProviderKeysFromExpression(expressionString); if (providerKeys.length === 0) { throw new BadRequestError( `Could not find a valid external secret vault name inside "${expressionString}" used in "${credentialProperty}"`, diff --git a/packages/cli/src/databases/repositories/__tests__/credentials.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/credentials.repository.test.ts index 71a93f8613c..e42a2e3ddb7 100644 --- a/packages/cli/src/databases/repositories/__tests__/credentials.repository.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/credentials.repository.test.ts @@ -144,7 +144,7 @@ describe('CredentialsRepository', () => { entityManager.find.mockResolvedValueOnce([globalCred]); // ACT - const credentials = await repository.findAllGlobalCredentials(true); + const credentials = await repository.findAllGlobalCredentials({ includeData: true }); // ASSERT expect(entityManager.find).toHaveBeenCalledWith( @@ -176,7 +176,7 @@ describe('CredentialsRepository', () => { entityManager.find.mockResolvedValueOnce([globalCred]); // ACT - const credentials = await repository.findAllGlobalCredentials(false); + const credentials = await repository.findAllGlobalCredentials({ includeData: false }); // ASSERT expect(entityManager.find).toHaveBeenCalledWith( 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 9702bd90de8..db6be9139b6 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 @@ -8,8 +8,10 @@ import type { import { In } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; import { CREDENTIAL_BLANKING_VALUE, type IDataObject, type INodeProperties } from 'n8n-workflow'; + import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { EventService } from '@/events/event.service'; +import type { CredentialDependencyService } from '@/credentials/credential-dependency.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'; @@ -18,6 +20,7 @@ import type { SecretsProvider } from '@/modules/external-secrets.ee/types'; describe('SecretsProvidersConnectionsService', () => { const mockRepository = mock(); const mockProjectAccessRepository = mock(); + const mockCredentialDependencyService = mock(); const mockExternalSecretsManager = mock(); const mockRedactionService = mock(); const mockProviderRegistry = mock(); @@ -31,6 +34,7 @@ describe('SecretsProvidersConnectionsService', () => { mockLogger(), mockRepository, mockProjectAccessRepository, + mockCredentialDependencyService, mockProviderRegistry, mockCipher as any, mockExternalSecretsManager, @@ -387,11 +391,31 @@ describe('SecretsProvidersConnectionsService', () => { }); it('should sync provider connection after deleteConnection', async () => { + const entityManager = { + delete: jest.fn().mockResolvedValue(undefined), + }; + const transaction = jest.fn( + async (fn: (em: typeof entityManager) => Promise) => await fn(entityManager), + ); + Object.defineProperty(mockRepository, 'manager', { + value: { transaction }, + configurable: true, + }); + mockRepository.findOne.mockResolvedValueOnce(savedConnection); - mockRepository.remove.mockResolvedValueOnce(savedConnection); await service.deleteConnection('my-aws', 'user-123'); + expect(mockProjectAccessRepository.deleteByConnectionId).toHaveBeenCalledWith( + 1, + entityManager, + ); + expect(mockCredentialDependencyService.deleteDependencyById).toHaveBeenCalledWith({ + dependencyType: 'externalSecretProvider', + dependencyId: '1', + entityManager, + }); + expect(entityManager.delete).toHaveBeenCalledWith(mockRepository.target, { id: 1 }); expect(mockExternalSecretsManager.syncProviderConnection).toHaveBeenCalledWith('my-aws'); }); }); @@ -429,10 +453,15 @@ describe('SecretsProvidersConnectionsService', () => { await service.cleanupConnectionsForProjectDeletion('project-1'); expect(transaction).toHaveBeenCalledTimes(1); - expect(entityManager.delete).toHaveBeenNthCalledWith(1, mockRepository.target, { + expect(mockCredentialDependencyService.deleteDependenciesByIds).toHaveBeenCalledWith({ + dependencyType: 'externalSecretProvider', + dependencyIds: ['1'], + entityManager, + }); + expect(entityManager.delete).toHaveBeenCalledWith(mockRepository.target, { id: In([1]), }); - expect(entityManager.delete).toHaveBeenNthCalledWith(2, mockProjectAccessRepository.target, { + expect(entityManager.delete).toHaveBeenCalledWith(mockProjectAccessRepository.target, { projectId: 'project-1', secretsProviderConnectionId: In([2]), }); @@ -449,6 +478,72 @@ describe('SecretsProvidersConnectionsService', () => { expect(mockExternalSecretsManager.syncProviderConnection).toHaveBeenCalledWith('provider-a'); expect(mockExternalSecretsManager.syncProviderConnection).toHaveBeenCalledWith('provider-b'); }); + + it('deletes credential dependencies for owner connections in a single bulk call', async () => { + const entityManager = { + delete: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + }; + const transaction = jest.fn( + async (callback: (em: typeof entityManager) => Promise) => + await callback(entityManager), + ); + Object.defineProperty(mockRepository, 'manager', { + value: { transaction }, + configurable: true, + }); + + mockProjectAccessRepository.findByProjectId.mockResolvedValue([ + mock({ + projectId: 'project-1', + role: 'secretsProviderConnection:owner', + secretsProviderConnectionId: 10, + secretsProviderConnection: { providerKey: 'provider-a' }, + }), + mock({ + projectId: 'project-1', + role: 'secretsProviderConnection:owner', + secretsProviderConnectionId: 11, + secretsProviderConnection: { providerKey: 'provider-b' }, + }), + ]); + + await service.cleanupConnectionsForProjectDeletion('project-1'); + + expect(mockCredentialDependencyService.deleteDependenciesByIds).toHaveBeenCalledWith({ + dependencyType: 'externalSecretProvider', + dependencyIds: ['10', '11'], + entityManager, + }); + }); + + it('does not delete credential dependencies when there are no owner connections', async () => { + const entityManager = { + delete: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + }; + const transaction = jest.fn( + async (callback: (em: typeof entityManager) => Promise) => + await callback(entityManager), + ); + Object.defineProperty(mockRepository, 'manager', { + value: { transaction }, + configurable: true, + }); + + mockProjectAccessRepository.findByProjectId.mockResolvedValue([ + mock({ + projectId: 'project-1', + role: 'secretsProviderConnection:user', + secretsProviderConnectionId: 12, + secretsProviderConnection: { providerKey: 'provider-c' }, + }), + ]); + + await service.cleanupConnectionsForProjectDeletion('project-1'); + + expect(mockCredentialDependencyService.deleteDependenciesByIds).not.toHaveBeenCalled(); + }); }); describe('event emissions', () => { @@ -678,31 +773,38 @@ describe('SecretsProvidersConnectionsService', () => { } as unknown as SecretsProviderConnection; it('should delete connection and sync provider when found', async () => { - mockRepository.removeByProviderKeyAndProjectId.mockResolvedValue(deletedConnection); + mockRepository.findByProviderKeyAndProjectId.mockResolvedValue(deletedConnection); + mockRepository.delete.mockResolvedValue({} as never); const result = await service.deleteConnectionForProject('my-aws', 'project-1'); expect(result).toBe(deletedConnection); - expect(mockRepository.removeByProviderKeyAndProjectId).toHaveBeenCalledWith( + expect(mockRepository.findByProviderKeyAndProjectId).toHaveBeenCalledWith( 'my-aws', 'project-1', ); + expect(mockCredentialDependencyService.deleteDependencyById).toHaveBeenCalledWith({ + dependencyType: 'externalSecretProvider', + dependencyId: '1', + }); expect(mockProjectAccessRepository.deleteByConnectionId).toHaveBeenCalledWith(1); + expect(mockRepository.delete).toHaveBeenCalledWith({ id: 1 }); expect(mockExternalSecretsManager.syncProviderConnection).toHaveBeenCalledWith('my-aws'); }); it('should throw NotFoundError when connection does not exist', async () => { - mockRepository.removeByProviderKeyAndProjectId.mockResolvedValue(null); + mockRepository.findByProviderKeyAndProjectId.mockResolvedValue(null); await expect(service.deleteConnectionForProject('missing', 'project-1')).rejects.toThrow( NotFoundError, ); + expect(mockCredentialDependencyService.deleteDependencyById).not.toHaveBeenCalled(); expect(mockProjectAccessRepository.deleteByConnectionId).not.toHaveBeenCalled(); expect(mockExternalSecretsManager.syncProviderConnection).not.toHaveBeenCalled(); }); it('should throw NotFoundError when connection does not belong to the project', async () => { - mockRepository.removeByProviderKeyAndProjectId.mockResolvedValue(null); + mockRepository.findByProviderKeyAndProjectId.mockResolvedValue(null); await expect(service.deleteConnectionForProject('my-aws', 'other-project')).rejects.toThrow( NotFoundError, 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 0fd32377607..a00f81553a1 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 @@ -21,6 +21,12 @@ import { Cipher } from 'n8n-core'; import type { IDataObject } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow'; +import { ExternalSecretsProviderRegistry } from './provider-registry.service'; + +import { + CredentialDependencyService, + EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, +} from '@/credentials/credential-dependency.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; @@ -28,14 +34,13 @@ import type { ProjectSummary } from '@/events/maps/relay.event-map'; import { ExternalSecretsManager } from '@/modules/external-secrets.ee/external-secrets-manager.ee'; import { RedactionService } from '@/modules/external-secrets.ee/redaction.service.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 credentialDependencyService: CredentialDependencyService, private readonly providerRegistry: ExternalSecretsProviderRegistry, private readonly cipher: Cipher, private readonly externalSecretsManager: ExternalSecretsManager, @@ -201,9 +206,17 @@ export class SecretsProvidersConnectionsService { async deleteConnection(providerKey: string, userId: string): Promise { const connection = await this.findConnectionOrFail(providerKey); const projectInfo = this.extractProjectInfo(connection); + const dependencyId = connection.id.toString(); - await this.projectAccessRepository.deleteByConnectionId(connection.id); - await this.repository.remove(connection); + await this.repository.manager.transaction(async (entityManager) => { + await this.projectAccessRepository.deleteByConnectionId(connection.id, entityManager); + await this.credentialDependencyService.deleteDependencyById({ + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyId, + entityManager, + }); + await entityManager.delete(this.repository.target, { id: connection.id }); + }); await this.externalSecretsManager.syncProviderConnection(providerKey); @@ -406,6 +419,12 @@ export class SecretsProvidersConnectionsService { // Wrap deletion + update ops in a transaction for consistency await this.repository.manager.transaction(async (entityManager) => { if (ownerConnectionIds.size > 0) { + await this.credentialDependencyService.deleteDependenciesByIds({ + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyIds: [...ownerConnectionIds].map((id) => id.toString()), + entityManager, + }); + // Delete owned connections entirely; DB cascade removes access entries await entityManager.delete(this.repository.target, { id: In([...ownerConnectionIds]) }); } @@ -466,16 +485,19 @@ export class SecretsProvidersConnectionsService { providerKey: string, projectId: string, ): Promise { - const connection = await this.repository.removeByProviderKeyAndProjectId( - providerKey, - projectId, - ); + const connection = await this.repository.findByProviderKeyAndProjectId(providerKey, projectId); if (!connection) { throw new NotFoundError(`Connection with key "${providerKey}" not found`); } - await this.projectAccessRepository.deleteByConnectionId(connection.id); + const connectionId = connection.id; + await this.credentialDependencyService.deleteDependencyById({ + dependencyType: EXTERNAL_SECRET_PROVIDER_DEPENDENCY_TYPE, + dependencyId: connectionId.toString(), + }); + await this.projectAccessRepository.deleteByConnectionId(connectionId); + await this.repository.delete({ id: connectionId }); await this.externalSecretsManager.syncProviderConnection(providerKey); return connection; diff --git a/packages/cli/src/public-api/v1/handlers/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/public-api/v1/handlers/credentials/__tests__/credentials.service.test.ts index 29336279596..a56f7782938 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/public-api/v1/handlers/credentials/__tests__/credentials.service.test.ts @@ -6,14 +6,14 @@ import { Cipher } from 'n8n-core'; import type { InstanceSettings } from 'n8n-core'; import type { GenericValue, IDataObject, INodeProperties } from 'n8n-workflow'; +import { buildSharedForCredential, toJsonSchema, updateCredential } from '../credentials.service'; + import { CredentialsService } from '@/credentials/credentials.service'; import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config'; import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee'; import * as checkAccess from '@/permissions.ee/check-access'; import type { IDependency } from '@/public-api/types'; -import { buildSharedForCredential, toJsonSchema, updateCredential } from '../credentials.service'; - // Set up real Cipher with mocked InstanceSettings for encryption const cipher = new Cipher(mock({ encryptionKey: 'test-encryption-key' })); Container.set(Cipher, cipher); @@ -417,6 +417,7 @@ describe('CredentialsService', () => { const credentialsService = new CredentialsService( mock(), // credentialsRepository + mock(), mock(), // sharedCredentialsRepository mock(), // ownershipService mock(), // logger @@ -492,7 +493,7 @@ describe('CredentialsService', () => { await expect( updateCredential('cred-id', memberUser, { - data: { apiKey: '{{ $secrets.myKey }}' }, + data: { apiKey: '{{ $secrets.vault.myKey }}' }, }), ).rejects.toThrow('Lacking permissions to reference external secrets in credentials'); }); @@ -513,11 +514,11 @@ describe('CredentialsService', () => { // Mock credential that already has secret expression jest .mocked(credentialsService.decrypt) - .mockReturnValue({ apiKey: '{{ $secrets.oldKey }}' }); + .mockReturnValue({ apiKey: '{{ $secrets.vault.oldKey }}' }); await expect( updateCredential('cred-id', memberUser, { - data: { apiKey: '{{ $secrets.newKey }}' }, + data: { apiKey: '{{ $secrets.vault.newKey }}' }, }), ).rejects.toThrow('Lacking permissions to reference external secrets in credentials'); }); @@ -566,7 +567,9 @@ describe('CredentialsService', () => { credentialsRepository.update = jest.fn().mockResolvedValue(undefined); // Mock credential that has existing secret expression - jest.mocked(credentialsService.decrypt).mockReturnValue({ apiKey: '{{ $secrets.myKey }}' }); + jest + .mocked(credentialsService.decrypt) + .mockReturnValue({ apiKey: '{{ $secrets.vault.myKey }}' }); credentialsRepository.update = jest.fn().mockResolvedValue(undefined); @@ -615,7 +618,7 @@ describe('CredentialsService', () => { credentialsRepository.update = jest.fn().mockResolvedValue(undefined); await updateCredential('cred-id', ownerUser, { - data: { apiKey: '{{ $secrets.myKey }}' }, + data: { apiKey: '{{ $secrets.vault.myKey }}' }, }); }); }); diff --git a/packages/cli/src/services/__tests__/credentials-tester.service.test.ts b/packages/cli/src/services/__tests__/credentials-tester.service.test.ts index 645f89ead93..156d5f15aba 100644 --- a/packages/cli/src/services/__tests__/credentials-tester.service.test.ts +++ b/packages/cli/src/services/__tests__/credentials-tester.service.test.ts @@ -105,6 +105,46 @@ describe('CredentialsTester', () => { expect(redactedMessage.message).toBe('Test failed for apiKey *****key'); }); + it('should redact secrets for bracket-notation external secret expressions', async () => { + mockTestFunction.mockResolvedValue({ + status: 'Error', + message: 'Test failed for apiKey secret_api_key', + }); + + const computedCredentialsData = { + testNestedData: { + access_token: 'abc123', + secretData: { + apiKey: 'secret_api_key', + }, + }, + }; + credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(computedCredentialsData); + + const rawCredentialsData = { + ...computedCredentialsData, + testNestedData: { + ...computedCredentialsData.testNestedData, + secretData: { + apiKey: "={{ $secrets['vault']['apiKey'] }}", + }, + }, + }; + const redactedMessage = await credentialsTester.testCredentials( + 'user-id', + 'testCredentials', + { + id: 'credential-id', + name: 'credential-name', + type: 'oAuth2Api', + data: rawCredentialsData, + }, + ); + + expect(redactedMessage.status).toBe('Error'); + expect(redactedMessage.message).toBe('Test failed for apiKey *****key'); + }); + it('should not redact secrets with value shorter than 3 characters', async () => { mockTestFunction.mockResolvedValue({ status: 'Error', diff --git a/packages/cli/src/services/credentials-tester.service.ts b/packages/cli/src/services/credentials-tester.service.ts index 2558e744606..5aa43b9ebe9 100644 --- a/packages/cli/src/services/credentials-tester.service.ts +++ b/packages/cli/src/services/credentials-tester.service.ts @@ -35,11 +35,11 @@ import { } from 'n8n-workflow'; import { RESPONSE_ERROR_MESSAGES } from '../constants'; +import { getExternalSecretExpressionPaths } from '../credentials/external-secrets.utils'; import { CredentialsHelper } from '../credentials-helper'; import { CredentialTypes } from '@/credential-types'; import { NodeTypes } from '@/node-types'; -import { getAllKeyPaths } from '@/utils'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; const { OAUTH2_CREDENTIAL_TEST_SUCCEEDED, OAUTH2_CREDENTIAL_TEST_FAILED } = RESPONSE_ERROR_MESSAGES; @@ -211,9 +211,7 @@ export class CredentialsTester { }); // Keep all credentials data keys which have a secret value - credentialsDataSecretKeys = getAllKeyPaths(credentialsDecrypted.data, '', [], (value) => - value.includes('$secrets.'), - ); + credentialsDataSecretKeys = getExternalSecretExpressionPaths(credentialsDecrypted.data); credentialsDecrypted.data = await this.credentialsHelper.applyDefaultsAndOverwrites( additionalData, credentialsDecrypted.data, diff --git a/packages/cli/test/migration/1773000000000-create-credential-dependency-table.test.ts b/packages/cli/test/migration/1773000000000-create-credential-dependency-table.test.ts new file mode 100644 index 00000000000..463ef61ccbe --- /dev/null +++ b/packages/cli/test/migration/1773000000000-create-credential-dependency-table.test.ts @@ -0,0 +1,217 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + undoLastSingleMigration, + type TestMigrationContext, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import { Cipher } from 'n8n-core'; +import { randomUUID } from 'node:crypto'; + +const MIGRATION_NAME = 'CreateCredentialDependencyTable1773000000000'; +const DEPENDENCY_TYPE = 'externalSecretProvider'; + +type CredentialDependencyRow = { + credentialId: string; + dependencyType: string; + dependencyId: string; +}; + +describe('CreateCredentialDependencyTable Migration', () => { + let dataSource: DataSource; + let cipher: Cipher; + jest.setTimeout(20_000); + + async function withContext(fn: (context: TestMigrationContext) => Promise): Promise { + const context = createTestMigrationContext(dataSource); + try { + return await fn(context); + } finally { + await context.queryRunner.release(); + } + } + + beforeAll(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.init(); + dataSource = Container.get(DataSource); + cipher = Container.get(Cipher); + }); + + beforeEach(async () => { + await withContext(async (context) => { + await context.queryRunner.clearDatabase(); + }); + await initDbUpToMigration(MIGRATION_NAME); + }); + + afterAll(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.close(); + }); + + async function insertProviderConnection( + context: TestMigrationContext, + data: { providerKey: string; type?: string }, + ): Promise { + const tableName = context.escape.tableName('secrets_provider_connection'); + const now = new Date(); + + await context.runQuery( + `INSERT INTO ${tableName} ("providerKey", "type", "encryptedSettings", "isEnabled", "createdAt", "updatedAt") + VALUES (:providerKey, :type, :encryptedSettings, :isEnabled, :createdAt, :updatedAt)`, + { + providerKey: data.providerKey, + type: data.type ?? data.providerKey, + encryptedSettings: cipher.encrypt(JSON.stringify({ region: 'us-east-1' })), + isEnabled: true, + createdAt: now, + updatedAt: now, + }, + ); + } + + async function getProviderIdByKey( + context: TestMigrationContext, + providerKey: string, + ): Promise { + const tableName = context.escape.tableName('secrets_provider_connection'); + const rows = await context.runQuery>( + `SELECT "id" AS "id" FROM ${tableName} WHERE "providerKey" = :providerKey`, + { providerKey }, + ); + + return rows[0]?.id.toString(); + } + + async function insertCredential( + context: TestMigrationContext, + data: { id: string; encryptedData: string }, + ): Promise { + const tableName = context.escape.tableName('credentials_entity'); + const now = new Date(); + + await context.runQuery( + `INSERT INTO ${tableName} ("id", "name", "data", "type", "createdAt", "updatedAt") + VALUES (:id, :name, :data, :type, :createdAt, :updatedAt)`, + { + id: data.id, + name: `cred-${data.id.slice(0, 8)}`, + data: data.encryptedData, + type: 'testApi', + createdAt: now, + updatedAt: now, + }, + ); + } + + async function getDependencies( + context: TestMigrationContext, + ): Promise { + const tableName = context.escape.tableName('credential_dependency'); + return await context.runQuery( + `SELECT "credentialId" AS "credentialId", "dependencyType" AS "dependencyType", "dependencyId" AS "dependencyId" + FROM ${tableName} + ORDER BY "credentialId", "dependencyId"`, + ); + } + + describe('up migration', () => { + it('should create table and backfill dependencies from credential expressions', async () => { + const credentialWithSecretsId = randomUUID(); + const credentialWithoutSecretsId = randomUUID(); + const credentialUnknownProviderId = randomUUID(); + + const { vaultProviderId, awsProviderId } = await withContext(async (context) => { + await insertProviderConnection(context, { providerKey: 'vault' }); + await insertProviderConnection(context, { providerKey: 'aws-secrets-manager' }); + + const vaultProviderId = await getProviderIdByKey(context, 'vault'); + const awsProviderId = await getProviderIdByKey(context, 'aws-secrets-manager'); + expect(vaultProviderId).toBeDefined(); + expect(awsProviderId).toBeDefined(); + + await insertCredential(context, { + id: credentialWithSecretsId, + encryptedData: cipher.encrypt( + JSON.stringify({ + apiKey: '={{ $secrets.vault.primaryKey }}', + nested: { + token: "={{ $secrets['aws-secrets-manager']['token'] }}", + }, + repeated: '={{ $secrets.vault.secondary + ":" + $secrets.vault.third }}', + }), + ), + }); + + await insertCredential(context, { + id: credentialWithoutSecretsId, + encryptedData: cipher.encrypt(JSON.stringify({ apiKey: 'plain-value' })), + }); + + await insertCredential(context, { + id: credentialUnknownProviderId, + encryptedData: cipher.encrypt(JSON.stringify({ apiKey: '={{ $secrets.unknown.key }}' })), + }); + + return { vaultProviderId, awsProviderId }; + }); + + await runSingleMigration(MIGRATION_NAME); + dataSource = Container.get(DataSource); + + const dependencies = await withContext(async (context) => await getDependencies(context)); + + expect(dependencies).toHaveLength(2); + expect(dependencies).toEqual( + expect.arrayContaining([ + { + credentialId: credentialWithSecretsId, + dependencyType: DEPENDENCY_TYPE, + dependencyId: vaultProviderId!, + }, + { + credentialId: credentialWithSecretsId, + dependencyType: DEPENDENCY_TYPE, + dependencyId: awsProviderId!, + }, + ]), + ); + }); + + it('should skip credentials that cannot be decrypted', async () => { + const invalidCredentialId = randomUUID(); + + await withContext(async (context) => { + await insertProviderConnection(context, { providerKey: 'vault' }); + await insertCredential(context, { + id: invalidCredentialId, + encryptedData: 'not-encrypted-data', + }); + }); + + await runSingleMigration(MIGRATION_NAME); + dataSource = Container.get(DataSource); + + const dependencies = await withContext(async (context) => await getDependencies(context)); + + expect(dependencies).toHaveLength(0); + }); + }); + + describe('down migration', () => { + it('should drop credential_dependency table', async () => { + await runSingleMigration(MIGRATION_NAME); + await undoLastSingleMigration(); + + await withContext(async (context) => { + const dependencyTableName = `${context.tablePrefix}credential_dependency`; + const hasDependencyTable = await context.queryRunner.hasTable(dependencyTableName); + expect(hasDependencyTable).toBe(false); + }); + }); + }); +});