mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-21 11:49:59 +00:00
feat: Introduce Azure Key Vault as external secrets provider (#10054)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { SecretsProviderSettings } from '@/Interfaces';
|
||||
|
||||
export type AzureKeyVaultContext = SecretsProviderSettings<{
|
||||
vaultName: string;
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}>;
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user