mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-20 11:22:15 +00:00
feat: Introduce Google Cloud Platform as external secrets provider (#10146)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user