feat: Introduce Google Cloud Platform as external secrets provider (#10146)

This commit is contained in:
Iván Ovejero 2024-07-30 14:58:25 +02:00 committed by GitHub
parent af695ebf93
commit 3ccb9df2f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 283 additions and 25 deletions

View file

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

View file

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

View file

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

View file

@ -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<string, string> = {
secret1: 'value1',
secret2: 'value2',
secret3: '', // no value
'#@&': 'value', // unsupported name
};
await gcpSecretsManager.init(
mock<GcpSecretsManagerContext>({
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
});
});

View file

@ -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'];
}
}

View file

@ -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<string, string> = {};
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<string[]>((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<Record<string, string>>((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<RawGcpSecretAccountKey>(privateKey, { fallbackValue: {} });
return {
projectId: parsed?.project_id ?? '',
clientEmail: parsed?.client_email ?? '',
privateKey: parsed?.private_key ?? '',
};
}
}

View file

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

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(2.000000, 6.000000)" fill="#4285F4" fill-rule="nonzero">
<path d="M20,9.41469125e-14 L20,11.8867925 L16.0377358,11.8867925 C15.9335312,11.8867925 15.8490566,11.8023179 15.8490566,11.6981132 L15.8490566,10.5660377 C15.8490566,10.4618331 15.9335312,10.3773585 16.0377358,10.3773585 L18.4900566,10.377 L18.4900566,1.509 L16.0377358,1.50943396 C15.9335312,1.50943396 15.8490566,1.42495939 15.8490566,1.32075472 L15.8490566,0.188679245 C15.8490566,0.0844745755 15.9335312,9.41105434e-14 16.0377358,9.41469125e-14 L20,9.41469125e-14 Z M4.309101,9.41469125e-14 C4.41330567,9.3877869e-14 4.49778024,0.0844745755 4.49778024,0.188679245 L4.49778024,1.32075472 C4.49778024,1.42495939 4.41330567,1.50943396 4.309101,1.50943396 L1.509,1.509 L1.509,10.377 L4.29245283,10.3773585 C4.3966575,10.3773585 4.48113208,10.4618331 4.48113208,10.5660377 L4.48113208,11.6981132 C4.48113208,11.8023179 4.3966575,11.8867925 4.29245283,11.8867925 L2.14050999e-13,11.8867925 L2.14050999e-13,9.41469125e-14 L4.309101,9.41469125e-14 Z M15.4271098,3.86792453 L15.4271098,5.3240566 L15.4879305,5.34852941 L16.8381494,4.87130966 L17.1179245,5.69114872 L15.7555414,6.15613208 L15.719049,6.25402331 L16.6556874,7.51437292 L15.9501676,8.02830189 L15.0378575,6.76795228 L14.9527085,6.76795228 L14.0403984,8.02830189 L13.3348786,7.51437292 L14.2593529,6.25402331 L14.2350246,6.15613208 L12.8726415,5.69114872 L13.1524166,4.87130966 L14.4904714,5.34852941 L14.5634562,5.3240566 L14.5634562,3.86792453 L15.4271098,3.86792453 Z M5.19597773,3.86792453 L5.19597773,5.30963945 L5.2567984,5.33386996 L6.60701735,4.86137515 L6.88679245,5.673097 L5.52440936,6.13347656 L5.48791696,6.23039857 L6.42455533,7.47826947 L5.71903552,7.98711003 L4.80672541,6.73923913 L4.72157647,6.73923913 L3.80926637,7.98711003 L3.10374655,7.47826947 L4.02822079,6.23039857 L4.00389252,6.13347656 L2.64150943,5.673097 L2.92128453,4.86137515 L4.25933935,5.33386996 L4.33232416,5.30963945 L4.33232416,3.86792453 L5.19597773,3.86792453 Z M10.2903173,3.86792453 L10.2903173,5.30963945 L10.351138,5.33386996 L11.701357,4.86137515 L11.9811321,5.673097 L10.618749,6.13347656 L10.5822566,6.23039857 L11.518895,7.47826947 L10.8133751,7.98711003 L9.90106504,6.73923913 L9.81591609,6.73923913 L8.90360599,7.98711003 L8.19808618,7.47826947 L9.12256042,6.23039857 L9.09823215,6.13347656 L7.73584906,5.673097 L8.01562416,4.86137515 L9.35367897,5.33386996 L9.42666378,5.30963945 L9.42666378,3.86792453 L10.2903173,3.86792453 Z" >
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -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],
);
</script>

View file

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