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