diff --git a/packages/cli/package.json b/packages/cli/package.json index b18513c3a2..60ace14d9b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -83,6 +83,7 @@ "dependencies": { "@azure/identity": "^4.3.0", "@azure/keyvault-secrets": "^4.8.0", + "@google-cloud/secret-manager": "^5.6.0", "@n8n/client-oauth2": "workspace:*", "@n8n/config": "workspace:*", "@n8n/localtunnel": "2.1.0", diff --git a/packages/cli/src/ExternalSecrets/ExternalSecretsProviders.ee.ts b/packages/cli/src/ExternalSecrets/ExternalSecretsProviders.ee.ts index 33228673c3..274a1d69aa 100644 --- a/packages/cli/src/ExternalSecrets/ExternalSecretsProviders.ee.ts +++ b/packages/cli/src/ExternalSecrets/ExternalSecretsProviders.ee.ts @@ -4,6 +4,7 @@ import { InfisicalProvider } from './providers/infisical'; import { VaultProvider } from './providers/vault'; import { AwsSecretsManager } from './providers/aws-secrets/aws-secrets-manager'; import { AzureKeyVault } from './providers/azure-key-vault/azure-key-vault'; +import { GcpSecretsManager } from './providers/gcp-secrets-manager/gcp-secrets-manager'; @Service() export class ExternalSecretsProviders { @@ -12,6 +13,7 @@ export class ExternalSecretsProviders { infisical: InfisicalProvider, vault: VaultProvider, azureKeyVault: AzureKeyVault, + gcpSecretsManager: GcpSecretsManager, }; getProvider(name: string): { new (): SecretsProvider } | null { diff --git a/packages/cli/src/ExternalSecrets/providers/__tests__/azure-key-vault.test.ts b/packages/cli/src/ExternalSecrets/providers/__tests__/azure-key-vault.test.ts index 329bfca2ac..8bacd6bbb9 100644 --- a/packages/cli/src/ExternalSecrets/providers/__tests__/azure-key-vault.test.ts +++ b/packages/cli/src/ExternalSecrets/providers/__tests__/azure-key-vault.test.ts @@ -37,7 +37,7 @@ describe('AzureKeyVault', () => { yield { name: 'secret1' }; yield { name: 'secret2' }; yield { name: 'secret3' }; // no value - yield { name: '#@&' }; // invalid name + yield { name: '#@&' }; // unsupported name }, })); @@ -65,6 +65,6 @@ describe('AzureKeyVault', () => { expect(azureKeyVault.getSecret('secret1')).toBe('value1'); expect(azureKeyVault.getSecret('secret2')).toBe('value2'); expect(azureKeyVault.getSecret('secret3')).toBeUndefined(); // no value - expect(azureKeyVault.getSecret('#@&')).toBeUndefined(); // invalid name + expect(azureKeyVault.getSecret('#@&')).toBeUndefined(); // unsupported name }); }); diff --git a/packages/cli/src/ExternalSecrets/providers/__tests__/gcp-secrets-manager.test.ts b/packages/cli/src/ExternalSecrets/providers/__tests__/gcp-secrets-manager.test.ts new file mode 100644 index 0000000000..40673041a7 --- /dev/null +++ b/packages/cli/src/ExternalSecrets/providers/__tests__/gcp-secrets-manager.test.ts @@ -0,0 +1,87 @@ +import { mock } from 'jest-mock-extended'; +import { GcpSecretsManager } from '../gcp-secrets-manager/gcp-secrets-manager'; +import type { GcpSecretsManagerContext } from '../gcp-secrets-manager/types'; +import { SecretManagerServiceClient } from '@google-cloud/secret-manager'; +import type { google } from '@google-cloud/secret-manager/build/protos/protos'; + +jest.mock('@google-cloud/secret-manager'); + +type GcpSecretVersionResponse = google.cloud.secretmanager.v1.IAccessSecretVersionResponse; + +describe('GCP Secrets Manager', () => { + const gcpSecretsManager = new GcpSecretsManager(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should update cached secrets', async () => { + /** + * Arrange + */ + const PROJECT_ID = 'my-project-id'; + + const SECRETS: Record = { + secret1: 'value1', + secret2: 'value2', + secret3: '', // no value + '#@&': 'value', // unsupported name + }; + + await gcpSecretsManager.init( + mock({ + settings: { serviceAccountKey: `{ "project_id": "${PROJECT_ID}" }` }, + }), + ); + + const listSpy = jest + .spyOn(SecretManagerServiceClient.prototype, 'listSecrets') + // @ts-expect-error Partial mock + .mockResolvedValue([ + [ + { name: `projects/${PROJECT_ID}/secrets/secret1` }, + { name: `projects/${PROJECT_ID}/secrets/secret2` }, + { name: `projects/${PROJECT_ID}/secrets/secret3` }, + { name: `projects/${PROJECT_ID}/secrets/#@&` }, + ], + ]); + + const getSpy = jest + .spyOn(SecretManagerServiceClient.prototype, 'accessSecretVersion') + .mockImplementation(async ({ name }: { name: string }) => { + const secretName = name.split('/')[3]; + return [ + { payload: { data: Buffer.from(SECRETS[secretName]) } }, + ] as GcpSecretVersionResponse[]; + }); + + /** + * Act + */ + await gcpSecretsManager.connect(); + await gcpSecretsManager.update(); + + /** + * Assert + */ + expect(listSpy).toHaveBeenCalled(); + + expect(getSpy).toHaveBeenCalledWith({ + name: `projects/${PROJECT_ID}/secrets/secret1/versions/latest`, + }); + expect(getSpy).toHaveBeenCalledWith({ + name: `projects/${PROJECT_ID}/secrets/secret2/versions/latest`, + }); + expect(getSpy).toHaveBeenCalledWith({ + name: `projects/${PROJECT_ID}/secrets/secret3/versions/latest`, + }); + expect(getSpy).not.toHaveBeenCalledWith({ + name: `projects/${PROJECT_ID}/secrets/#@&/versions/latest`, + }); + + expect(gcpSecretsManager.getSecret('secret1')).toBe('value1'); + expect(gcpSecretsManager.getSecret('secret2')).toBe('value2'); + expect(gcpSecretsManager.getSecret('secret3')).toBeUndefined(); // no value + expect(gcpSecretsManager.getSecret('#@&')).toBeUndefined(); // unsupported name + }); +}); diff --git a/packages/cli/src/ExternalSecrets/providers/azure-key-vault/azure-key-vault.ts b/packages/cli/src/ExternalSecrets/providers/azure-key-vault/azure-key-vault.ts index c51e54e799..7df196bdf9 100644 --- a/packages/cli/src/ExternalSecrets/providers/azure-key-vault/azure-key-vault.ts +++ b/packages/cli/src/ExternalSecrets/providers/azure-key-vault/azure-key-vault.ts @@ -74,7 +74,7 @@ export class AzureKeyVault implements SecretsProvider { const credential = new ClientSecretCredential(tenantId, clientId, clientSecret); this.client = new SecretClient(`https://${vaultName}.vault.azure.net/`, credential); this.state = 'connected'; - } catch (error) { + } catch { this.state = 'error'; } } @@ -86,7 +86,7 @@ export class AzureKeyVault implements SecretsProvider { await this.client.listPropertiesOfSecrets().next(); return [true]; } catch (error: unknown) { - return [false, error instanceof Error ? error.message : 'unknown error']; + return [false, error instanceof Error ? error.message : 'Unknown error']; } } diff --git a/packages/cli/src/ExternalSecrets/providers/gcp-secrets-manager/gcp-secrets-manager.ts b/packages/cli/src/ExternalSecrets/providers/gcp-secrets-manager/gcp-secrets-manager.ts new file mode 100644 index 0000000000..64ed49f05c --- /dev/null +++ b/packages/cli/src/ExternalSecrets/providers/gcp-secrets-manager/gcp-secrets-manager.ts @@ -0,0 +1,136 @@ +import { SecretManagerServiceClient as GcpClient } from '@google-cloud/secret-manager'; +import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/ExternalSecrets/constants'; +import type { SecretsProvider, SecretsProviderState } from '@/Interfaces'; +import { jsonParse, type INodeProperties } from 'n8n-workflow'; +import type { + GcpSecretsManagerContext, + GcpSecretAccountKey, + RawGcpSecretAccountKey, +} from './types'; + +export class GcpSecretsManager implements SecretsProvider { + name = 'gcpSecretsManager'; + + displayName = 'GCP Secrets Manager'; + + state: SecretsProviderState = 'initializing'; + + properties: INodeProperties[] = [ + DOCS_HELP_NOTICE, + { + displayName: 'Service Account Key', + name: 'serviceAccountKey', + type: 'string', + default: '', + required: true, + typeOptions: { password: true }, + placeholder: 'e.g. { "type": "service_account", "project_id": "gcp-secrets-store", ... }', + hint: 'Content of JSON file downloaded from Google Cloud Console.', + noDataExpression: true, + }, + ]; + + private cachedSecrets: Record = {}; + + private client: GcpClient; + + private settings: GcpSecretAccountKey; + + async init(context: GcpSecretsManagerContext) { + this.settings = this.parseSecretAccountKey(context.settings.serviceAccountKey); + } + + async connect() { + const { projectId, privateKey, clientEmail } = this.settings; + + try { + this.client = new GcpClient({ + credentials: { client_email: clientEmail, private_key: privateKey }, + projectId, + }); + this.state = 'connected'; + } catch { + this.state = 'error'; + } + } + + async test(): Promise<[boolean] | [boolean, string]> { + if (!this.client) return [false, 'Failed to connect to GCP Secrets Manager']; + + try { + await this.client.initialize(); + return [true]; + } catch (error: unknown) { + return [false, error instanceof Error ? error.message : 'Unknown error']; + } + } + + async disconnect() { + // unused + } + + async update() { + const { projectId } = this.settings; + + const [rawSecretNames] = await this.client.listSecrets({ + parent: `projects/${projectId}`, + }); + + const secretNames = rawSecretNames.reduce((acc, cur) => { + if (!cur.name || !EXTERNAL_SECRETS_NAME_REGEX.test(cur.name)) return acc; + + const secretName = cur.name.split('/').pop(); + + if (secretName) acc.push(secretName); + + return acc; + }, []); + + const promises = secretNames.map(async (name) => { + const versions = await this.client.accessSecretVersion({ + name: `projects/${projectId}/secrets/${name}/versions/latest`, + }); + + if (!Array.isArray(versions) || !versions.length) return null; + + const [latestVersion] = versions; + + if (!latestVersion.payload?.data) return null; + + const value = latestVersion.payload.data.toString(); + + if (!value) return null; + + return { name, value }; + }); + + const results = await Promise.all(promises); + + this.cachedSecrets = results.reduce>((acc, cur) => { + if (cur) acc[cur.name] = cur.value; + return acc; + }, {}); + } + + getSecret(name: string) { + return this.cachedSecrets[name]; + } + + hasSecret(name: string) { + return name in this.cachedSecrets; + } + + getSecretNames() { + return Object.keys(this.cachedSecrets); + } + + private parseSecretAccountKey(privateKey: string): GcpSecretAccountKey { + const parsed = jsonParse(privateKey, { fallbackValue: {} }); + + return { + projectId: parsed?.project_id ?? '', + clientEmail: parsed?.client_email ?? '', + privateKey: parsed?.private_key ?? '', + }; + } +} diff --git a/packages/cli/src/ExternalSecrets/providers/gcp-secrets-manager/types.ts b/packages/cli/src/ExternalSecrets/providers/gcp-secrets-manager/types.ts new file mode 100644 index 0000000000..c9611df9c4 --- /dev/null +++ b/packages/cli/src/ExternalSecrets/providers/gcp-secrets-manager/types.ts @@ -0,0 +1,19 @@ +import type { SecretsProviderSettings } from '@/Interfaces'; + +type JsonString = string; + +export type GcpSecretsManagerContext = SecretsProviderSettings<{ + serviceAccountKey: JsonString; +}>; + +export type RawGcpSecretAccountKey = { + project_id?: string; + private_key?: string; + client_email?: string; +}; + +export type GcpSecretAccountKey = { + projectId: string; + clientEmail: string; + privateKey: string; +}; diff --git a/packages/editor-ui/src/assets/images/gcp-secrets-manager.svg b/packages/editor-ui/src/assets/images/gcp-secrets-manager.svg new file mode 100644 index 0000000000..a6e07c7681 --- /dev/null +++ b/packages/editor-ui/src/assets/images/gcp-secrets-manager.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/editor-ui/src/components/ExternalSecretsProviderImage.ee.vue b/packages/editor-ui/src/components/ExternalSecretsProviderImage.ee.vue index dc40fed88c..8d8eec9109 100644 --- a/packages/editor-ui/src/components/ExternalSecretsProviderImage.ee.vue +++ b/packages/editor-ui/src/components/ExternalSecretsProviderImage.ee.vue @@ -7,6 +7,7 @@ import doppler from '../assets/images/doppler.webp'; import vault from '../assets/images/hashicorp.webp'; import awsSecretsManager from '../assets/images/aws-secrets-manager.svg'; import azureKeyVault from '../assets/images/azure-key-vault.svg'; +import gcpSecretsManager from '../assets/images/gcp-secrets-manager.svg'; const props = defineProps<{ provider: ExternalSecretsProvider; @@ -20,6 +21,7 @@ const image = computed( vault, awsSecretsManager, azureKeyVault, + gcpSecretsManager, })[props.provider.name], ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58fd67d517..005cdc3101 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -608,6 +608,9 @@ importers: '@azure/keyvault-secrets': specifier: ^4.8.0 version: 4.8.0 + '@google-cloud/secret-manager': + specifier: ^5.6.0 + version: 5.6.0(encoding@0.1.13) '@n8n/client-oauth2': specifier: workspace:* version: link:../@n8n/client-oauth2 @@ -3360,6 +3363,10 @@ packages: resolution: {integrity: sha512-uWJJf6S2PJL7oZ4ezv16aZl9+IJqPo5GzUv1pZ3/qRiMj13p0ylEgX1+LxBpX71eEPKTwMHoJV2IBBe3EAq7Xw==} engines: {node: '>=14.0.0'} + '@google-cloud/secret-manager@5.6.0': + resolution: {integrity: sha512-0daW/OXQEVc6VQKPyJTQNyD+563I/TYQ7GCQJx4dq3lB666R9FUPvqHx9b/o/qQtZ5pfuoCbGZl3krpxgTSW8Q==} + engines: {node: '>=14.0.0'} + '@google-cloud/storage@6.11.0': resolution: {integrity: sha512-p5VX5K2zLTrMXlKdS1CiQNkKpygyn7CBFm5ZvfhVj6+7QUsjWvYx9YDMkYXdarZ6JDt4cxiu451y9QUIH82ZTw==} engines: {node: '>=12'} @@ -3376,11 +3383,6 @@ packages: resolution: {integrity: sha512-vYVqYzHicDqyKB+NQhAc54I1QWCBLCrYG6unqOIcBTHx+7x8C9lcoLj3KVJXs2VB4lUbpWY+Kk9NipcbXYWmvg==} engines: {node: '>=12.10.0'} - '@grpc/proto-loader@0.7.10': - resolution: {integrity: sha512-CAqDfoaQ8ykFd9zqBDn4k6iWT9loLAlc2ETmDFS9JCD70gDcnA4L3AFEo2iV7KyAtAAHFW9ftq1Fz+Vsgq80RQ==} - engines: {node: '>=6'} - hasBin: true - '@grpc/proto-loader@0.7.13': resolution: {integrity: sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==} engines: {node: '>=6'} @@ -15843,6 +15845,13 @@ snapshots: - encoding - supports-color + '@google-cloud/secret-manager@5.6.0(encoding@0.1.13)': + dependencies: + google-gax: 4.3.4(encoding@0.1.13) + transitivePeerDependencies: + - encoding + - supports-color + '@google-cloud/storage@6.11.0(encoding@0.1.13)': dependencies: '@google-cloud/paginator': 3.0.7 @@ -15875,13 +15884,6 @@ snapshots: '@grpc/proto-loader': 0.7.13 '@js-sdsl/ordered-map': 4.4.2 - '@grpc/proto-loader@0.7.10': - dependencies: - lodash.camelcase: 4.3.0 - long: 5.2.3 - protobufjs: 7.3.0 - yargs: 17.7.2 - '@grpc/proto-loader@0.7.13': dependencies: lodash.camelcase: 4.3.0 @@ -21305,7 +21307,7 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) is-core-module: 2.13.1 resolve: 1.22.8 transitivePeerDependencies: @@ -21330,7 +21332,7 @@ snapshots: eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.2))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.5.2) eslint: 8.57.0 @@ -21350,7 +21352,7 @@ snapshots: array.prototype.findlastindex: 1.2.3 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 @@ -21880,7 +21882,7 @@ snapshots: follow-redirects@1.15.6(debug@3.2.7): optionalDependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) follow-redirects@1.15.6(debug@4.3.4): optionalDependencies: @@ -22221,7 +22223,7 @@ snapshots: array-parallel: 0.1.3 array-series: 0.1.5 cross-spawn: 4.0.2 - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -22255,7 +22257,7 @@ snapshots: google-gax@4.3.4(encoding@0.1.13): dependencies: '@grpc/grpc-js': 1.10.8 - '@grpc/proto-loader': 0.7.10 + '@grpc/proto-loader': 0.7.13 '@types/long': 4.0.2 abort-controller: 3.0.0 duplexify: 4.1.2 @@ -24914,7 +24916,7 @@ snapshots: pdf-parse@1.1.1: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) node-ensure: 0.0.0 transitivePeerDependencies: - supports-color @@ -25826,7 +25828,7 @@ snapshots: rhea@1.0.24: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -26200,7 +26202,7 @@ snapshots: binascii: 0.0.2 bn.js: 5.2.1 browser-request: 0.3.3 - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) expand-tilde: 2.0.2 extend: 3.0.2 fast-xml-parser: 4.2.7