feat: Introduce Azure Key Vault as external secrets provider (#10054)

This commit is contained in:
Iván Ovejero
2024-07-18 15:51:48 +02:00
committed by GitHub
parent 5a9a2713b4
commit 1b6c2d3a37
12 changed files with 361 additions and 46 deletions

View File

@@ -81,6 +81,8 @@
"ts-essentials": "^7.0.3"
},
"dependencies": {
"@azure/identity": "^4.3.0",
"@azure/keyvault-secrets": "^4.8.0",
"@n8n/client-oauth2": "workspace:*",
"@n8n/config": "workspace:*",
"@n8n/localtunnel": "2.1.0",

View File

@@ -3,6 +3,7 @@ import { Service } from 'typedi';
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';
@Service()
export class ExternalSecretsProviders {
@@ -10,6 +11,7 @@ export class ExternalSecretsProviders {
awsSecretsManager: AwsSecretsManager,
infisical: InfisicalProvider,
vault: VaultProvider,
azureKeyVault: AzureKeyVault,
};
getProvider(name: string): { new (): SecretsProvider } | null {

View File

@@ -1,5 +1,16 @@
import type { INodeProperties } from 'n8n-workflow';
export const EXTERNAL_SECRETS_DB_KEY = 'feature.externalSecrets';
export const EXTERNAL_SECRETS_INITIAL_BACKOFF = 10 * 1000;
export const EXTERNAL_SECRETS_MAX_BACKOFF = 5 * 60 * 1000;
export const EXTERNAL_SECRETS_NAME_REGEX = /^[a-zA-Z0-9\-\_\/]+$/;
export const DOCS_HELP_NOTICE: INodeProperties = {
displayName:
'Need help filling out these fields? <a href="https://docs.n8n.io/external-secrets/#connect-n8n-to-your-secrets-store" target="_blank">Open docs</a>',
name: 'notice',
type: 'notice',
default: '',
noDataExpression: true,
};

View File

@@ -0,0 +1,70 @@
import { SecretClient } from '@azure/keyvault-secrets';
import type { KeyVaultSecret } from '@azure/keyvault-secrets';
import { AzureKeyVault } from '../azure-key-vault/azure-key-vault';
import { mock } from 'jest-mock-extended';
import type { AzureKeyVaultContext } from '../azure-key-vault/types';
jest.mock('@azure/identity');
jest.mock('@azure/keyvault-secrets');
describe('AzureKeyVault', () => {
const azureKeyVault = new AzureKeyVault();
afterEach(() => {
jest.clearAllMocks();
});
it('should update cached secrets', async () => {
/**
* Arrange
*/
await azureKeyVault.init(
mock<AzureKeyVaultContext>({
settings: {
vaultName: 'my-vault',
tenantId: 'my-tenant-id',
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
},
}),
);
const listSpy = jest
.spyOn(SecretClient.prototype, 'listPropertiesOfSecrets')
// @ts-expect-error Partial mock
.mockImplementation(() => ({
async *[Symbol.asyncIterator]() {
yield { name: 'secret1' };
yield { name: 'secret2' };
yield { name: 'secret3' }; // no value
yield { name: '#@&' }; // invalid name
},
}));
const getSpy = jest
.spyOn(SecretClient.prototype, 'getSecret')
.mockImplementation(async (name: string) => {
return mock<KeyVaultSecret>({ value: { secret1: 'value1', secret2: 'value2' }[name] });
});
/**
* Act
*/
await azureKeyVault.connect();
await azureKeyVault.update();
/**
* Assert
*/
expect(listSpy).toHaveBeenCalled();
expect(getSpy).toHaveBeenCalledWith('secret1');
expect(getSpy).toHaveBeenCalledWith('secret2');
expect(getSpy).toHaveBeenCalledWith('secret3');
expect(getSpy).not.toHaveBeenCalledWith('#@&');
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
});
});

View File

@@ -1,6 +1,6 @@
import { AwsSecretsClient } from './aws-secrets-client';
import { UnknownAuthTypeError } from '@/errors/unknown-auth-type.error';
import { EXTERNAL_SECRETS_NAME_REGEX } from '@/ExternalSecrets/constants';
import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/ExternalSecrets/constants';
import type { SecretsProvider, SecretsProviderState } from '@/Interfaces';
import type { INodeProperties } from 'n8n-workflow';
import type { AwsSecretsManagerContext } from './types';
@@ -13,14 +13,7 @@ export class AwsSecretsManager implements SecretsProvider {
state: SecretsProviderState = 'initializing';
properties: INodeProperties[] = [
{
displayName:
'Need help filling out these fields? <a href="https://docs.n8n.io/external-secrets/#connect-n8n-to-your-secrets-store" target="_blank">Open docs</a>',
name: 'notice',
type: 'notice',
default: '',
noDataExpression: true,
},
DOCS_HELP_NOTICE,
{
displayName: 'Region',
name: 'region',

View File

@@ -0,0 +1,131 @@
import { ClientSecretCredential } from '@azure/identity';
import { SecretClient } from '@azure/keyvault-secrets';
import type { SecretsProvider, SecretsProviderState } from '@/Interfaces';
import type { INodeProperties } from 'n8n-workflow';
import type { AzureKeyVaultContext } from './types';
import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '@/ExternalSecrets/constants';
export class AzureKeyVault implements SecretsProvider {
name = 'azureKeyVault';
displayName = 'Azure Key Vault';
state: SecretsProviderState = 'initializing';
properties: INodeProperties[] = [
DOCS_HELP_NOTICE,
{
displayName: 'Vault Name',
hint: 'The name of your existing Azure Key Vault.',
name: 'vaultName',
type: 'string',
default: '',
required: true,
placeholder: 'e.g. my-vault',
noDataExpression: true,
},
{
displayName: 'Tenant ID',
name: 'tenantId',
hint: 'In Azure, this can be called "Directory (Tenant) ID".',
type: 'string',
default: '',
required: true,
placeholder: 'e.g. 7dec9324-7074-72b7-a3ca-a9bb3012f466',
noDataExpression: true,
},
{
displayName: 'Client ID',
name: 'clientId',
hint: 'In Azure, this can be called "Application (Client) ID".',
type: 'string',
default: '',
required: true,
placeholder: 'e.g. 7753d8c2-e41f-22ed-3dd7-c9e96463622c',
typeOptions: { password: true },
noDataExpression: true,
},
{
displayName: 'Client Secret',
name: 'clientSecret',
hint: 'The client secret value of your registered application.',
type: 'string',
default: '',
required: true,
typeOptions: { password: true },
noDataExpression: true,
},
];
private cachedSecrets: Record<string, string> = {};
private client: SecretClient;
private settings: AzureKeyVaultContext['settings'];
async init(context: AzureKeyVaultContext) {
this.settings = context.settings;
}
async connect() {
const { vaultName, tenantId, clientId, clientSecret } = this.settings;
try {
const credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
this.client = new SecretClient(`https://${vaultName}.vault.azure.net/`, credential);
this.state = 'connected';
} catch (error) {
this.state = 'error';
}
}
async test(): Promise<[boolean] | [boolean, string]> {
if (!this.client) return [false, 'Failed to connect to Azure Key Vault'];
try {
await this.client.listPropertiesOfSecrets().next();
return [true];
} catch (error: unknown) {
return [false, error instanceof Error ? error.message : 'unknown error'];
}
}
async disconnect() {
// unused
}
async update() {
const secretNames: string[] = [];
for await (const secret of this.client.listPropertiesOfSecrets()) {
secretNames.push(secret.name);
}
const promises = secretNames
.filter((name) => EXTERNAL_SECRETS_NAME_REGEX.test(name))
.map(async (name) => {
const { value } = await this.client.getSecret(name);
return { name, value };
});
const secrets = await Promise.all(promises);
this.cachedSecrets = secrets.reduce<Record<string, string>>((acc, cur) => {
if (cur.value === undefined) return acc;
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);
}
}

View File

@@ -0,0 +1,8 @@
import type { SecretsProviderSettings } from '@/Interfaces';
export type AzureKeyVaultContext = SecretsProviderSettings<{
vaultName: string;
tenantId: string;
clientId: string;
clientSecret: string;
}>;

View File

@@ -3,7 +3,7 @@ import InfisicalClient from 'infisical-node';
import { populateClientWorkspaceConfigsHelper } from 'infisical-node/lib/helpers/key';
import { getServiceTokenData } from 'infisical-node/lib/api/serviceTokenData';
import { ApplicationError, type IDataObject, type INodeProperties } from 'n8n-workflow';
import { EXTERNAL_SECRETS_NAME_REGEX } from '../constants';
import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '../constants';
export interface InfisicalSettings {
token: string;
@@ -24,13 +24,7 @@ interface InfisicalServiceToken {
export class InfisicalProvider implements SecretsProvider {
properties: INodeProperties[] = [
{
displayName:
'Need help filling out these fields? <a href="https://docs.n8n.io/external-secrets/#connect-n8n-to-your-secrets-store" target="_blank">Open docs</a>',
name: 'notice',
type: 'notice',
default: '',
},
DOCS_HELP_NOTICE,
{
displayName: 'Service Token',
name: 'token',

View File

@@ -4,7 +4,7 @@ import type { IDataObject, INodeProperties } from 'n8n-workflow';
import type { AxiosInstance, AxiosResponse } from 'axios';
import axios from 'axios';
import { Logger } from '@/Logger';
import { EXTERNAL_SECRETS_NAME_REGEX } from '../constants';
import { DOCS_HELP_NOTICE, EXTERNAL_SECRETS_NAME_REGEX } from '../constants';
import { preferGet } from '../externalSecretsHelper.ee';
import { Container } from 'typedi';
@@ -85,13 +85,7 @@ interface VaultSecretList {
export class VaultProvider extends SecretsProvider {
properties: INodeProperties[] = [
{
displayName:
'Need help filling out these fields? <a href="https://docs.n8n.io/external-secrets/#connect-n8n-to-your-secrets-store" target="_blank">Open docs</a>',
name: 'notice',
type: 'notice',
default: '',
},
DOCS_HELP_NOTICE,
{
displayName: 'Vault URL',
name: 'url',