mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
feat(core): Make Azure Key Vault provider endpoints configurable (#35214)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+134
-61
@@ -1,4 +1,4 @@
|
||||
import { AuthenticationError } from '@azure/identity';
|
||||
import { AuthenticationError, ClientSecretCredential } from '@azure/identity';
|
||||
import { SecretClient } from '@azure/keyvault-secrets';
|
||||
import type { KeyVaultSecret } from '@azure/keyvault-secrets';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
@@ -12,6 +12,17 @@ import type { AzureKeyVaultContext } from '../azure-key-vault/types';
|
||||
vi.mock('@azure/identity');
|
||||
vi.mock('@azure/keyvault-secrets');
|
||||
|
||||
const baseSettings: AzureKeyVaultContext['settings'] = {
|
||||
vaultName: 'my-vault',
|
||||
tenantId: 'my-tenant-id',
|
||||
clientId: 'my-client-id',
|
||||
clientSecret: 'my-client-secret',
|
||||
};
|
||||
|
||||
function createSettingsContext(settings: AzureKeyVaultContext['settings']): AzureKeyVaultContext {
|
||||
return { connected: false, connectedAt: null, settings };
|
||||
}
|
||||
|
||||
function createRestErrorLike(
|
||||
message: string,
|
||||
{ statusCode, code }: { statusCode?: number; code?: string },
|
||||
@@ -86,17 +97,124 @@ describe('AzureKeyVault', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('endpoint resolution', () => {
|
||||
const ClientSecretCredentialMock = ClientSecretCredential as unknown as Mock;
|
||||
const SecretClientMock = SecretClient as unknown as Mock;
|
||||
|
||||
it('should use commercial endpoints when no environment is set', async () => {
|
||||
await azureKeyVault.init(createSettingsContext(baseSettings));
|
||||
await azureKeyVault.connect();
|
||||
|
||||
expect(ClientSecretCredentialMock).toHaveBeenCalledWith(
|
||||
'my-tenant-id',
|
||||
'my-client-id',
|
||||
'my-client-secret',
|
||||
{ authorityHost: 'https://login.microsoftonline.com' },
|
||||
);
|
||||
expect(SecretClientMock).toHaveBeenCalledWith(
|
||||
'https://my-vault.vault.azure.net/',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use commercial endpoints for the public environment', async () => {
|
||||
await azureKeyVault.init(createSettingsContext({ ...baseSettings, environment: 'public' }));
|
||||
await azureKeyVault.connect();
|
||||
|
||||
expect(ClientSecretCredentialMock).toHaveBeenCalledWith(
|
||||
'my-tenant-id',
|
||||
'my-client-id',
|
||||
'my-client-secret',
|
||||
{ authorityHost: 'https://login.microsoftonline.com' },
|
||||
);
|
||||
expect(SecretClientMock).toHaveBeenCalledWith(
|
||||
'https://my-vault.vault.azure.net/',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use US Government endpoints for the usGovernment environment', async () => {
|
||||
await azureKeyVault.init(
|
||||
createSettingsContext({ ...baseSettings, environment: 'usGovernment' }),
|
||||
);
|
||||
await azureKeyVault.connect();
|
||||
|
||||
expect(ClientSecretCredentialMock).toHaveBeenCalledWith(
|
||||
'my-tenant-id',
|
||||
'my-client-id',
|
||||
'my-client-secret',
|
||||
{ authorityHost: 'https://login.microsoftonline.us' },
|
||||
);
|
||||
expect(SecretClientMock).toHaveBeenCalledWith(
|
||||
'https://my-vault.vault.usgovcloudapi.net/',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use China endpoints for the china environment', async () => {
|
||||
await azureKeyVault.init(createSettingsContext({ ...baseSettings, environment: 'china' }));
|
||||
await azureKeyVault.connect();
|
||||
|
||||
expect(ClientSecretCredentialMock).toHaveBeenCalledWith(
|
||||
'my-tenant-id',
|
||||
'my-client-id',
|
||||
'my-client-secret',
|
||||
{ authorityHost: 'https://login.partner.microsoftonline.cn' },
|
||||
);
|
||||
expect(SecretClientMock).toHaveBeenCalledWith(
|
||||
'https://my-vault.vault.azure.cn/',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use the provided vault URL and authority host for the custom environment', async () => {
|
||||
await azureKeyVault.init(
|
||||
createSettingsContext({
|
||||
...baseSettings,
|
||||
environment: 'custom',
|
||||
vaultUrl: 'https://my-vault.keyvault.internal.example.com ',
|
||||
authorityHost: ' https://login.internal.example.com',
|
||||
}),
|
||||
);
|
||||
await azureKeyVault.connect();
|
||||
|
||||
expect(ClientSecretCredentialMock).toHaveBeenCalledWith(
|
||||
'my-tenant-id',
|
||||
'my-client-id',
|
||||
'my-client-secret',
|
||||
{ authorityHost: 'https://login.internal.example.com' },
|
||||
);
|
||||
expect(SecretClientMock).toHaveBeenCalledWith(
|
||||
'https://my-vault.keyvault.internal.example.com',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to the commercial authority host when the custom environment has none', async () => {
|
||||
await azureKeyVault.init(
|
||||
createSettingsContext({
|
||||
...baseSettings,
|
||||
environment: 'custom',
|
||||
vaultUrl: 'https://my-vault.keyvault.internal.example.com',
|
||||
}),
|
||||
);
|
||||
await azureKeyVault.connect();
|
||||
|
||||
expect(ClientSecretCredentialMock).toHaveBeenCalledWith(
|
||||
'my-tenant-id',
|
||||
'my-client-id',
|
||||
'my-client-secret',
|
||||
{ authorityHost: 'https://login.microsoftonline.com' },
|
||||
);
|
||||
expect(SecretClientMock).toHaveBeenCalledWith(
|
||||
'https://my-vault.keyvault.internal.example.com',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should log failed client setup while preserving error state', async () => {
|
||||
await azureKeyVault.init(
|
||||
mock<AzureKeyVaultContext>({
|
||||
settings: {
|
||||
vaultName: 'my-vault',
|
||||
tenantId: 'my-tenant-id',
|
||||
clientId: 'my-client-id',
|
||||
clientSecret: 'my-client-secret',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await azureKeyVault.init(createSettingsContext(baseSettings));
|
||||
|
||||
const setupError = new Error('Invalid configuration');
|
||||
const SecretClientMock = SecretClient as unknown as Mock;
|
||||
@@ -121,16 +239,7 @@ describe('AzureKeyVault', () => {
|
||||
});
|
||||
|
||||
it('should log test failures with Azure error context', async () => {
|
||||
await azureKeyVault.init(
|
||||
mock<AzureKeyVaultContext>({
|
||||
settings: {
|
||||
vaultName: 'my-vault',
|
||||
tenantId: 'my-tenant-id',
|
||||
clientId: 'my-client-id',
|
||||
clientSecret: 'my-client-secret',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await azureKeyVault.init(createSettingsContext(baseSettings));
|
||||
|
||||
await azureKeyVault.connect();
|
||||
|
||||
@@ -165,16 +274,7 @@ describe('AzureKeyVault', () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
await azureKeyVault.init(
|
||||
mock<AzureKeyVaultContext>({
|
||||
settings: {
|
||||
vaultName: 'my-vault',
|
||||
tenantId: 'my-tenant-id',
|
||||
clientId: 'my-client-id',
|
||||
clientSecret: 'my-client-secret',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await azureKeyVault.init(createSettingsContext(baseSettings));
|
||||
|
||||
const listSpy = vi.spyOn(SecretClient.prototype, 'listPropertiesOfSecrets').mockImplementation(
|
||||
() =>
|
||||
@@ -213,16 +313,7 @@ describe('AzureKeyVault', () => {
|
||||
});
|
||||
|
||||
it('should skip disabled secrets without calling getSecret', async () => {
|
||||
await azureKeyVault.init(
|
||||
mock<AzureKeyVaultContext>({
|
||||
settings: {
|
||||
vaultName: 'my-vault',
|
||||
tenantId: 'my-tenant-id',
|
||||
clientId: 'my-client-id',
|
||||
clientSecret: 'my-client-secret',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await azureKeyVault.init(createSettingsContext(baseSettings));
|
||||
|
||||
const listSpy = vi.spyOn(SecretClient.prototype, 'listPropertiesOfSecrets').mockImplementation(
|
||||
() =>
|
||||
@@ -249,16 +340,7 @@ describe('AzureKeyVault', () => {
|
||||
});
|
||||
|
||||
it('should still load other secrets when one getSecret fails', async () => {
|
||||
await azureKeyVault.init(
|
||||
mock<AzureKeyVaultContext>({
|
||||
settings: {
|
||||
vaultName: 'my-vault',
|
||||
tenantId: 'my-tenant-id',
|
||||
clientId: 'my-client-id',
|
||||
clientSecret: 'my-client-secret',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await azureKeyVault.init(createSettingsContext(baseSettings));
|
||||
|
||||
vi.spyOn(SecretClient.prototype, 'listPropertiesOfSecrets').mockImplementation(
|
||||
() =>
|
||||
@@ -304,16 +386,7 @@ describe('AzureKeyVault', () => {
|
||||
});
|
||||
|
||||
it('should throw when every getSecret fails and leave the previous cache unchanged', async () => {
|
||||
await azureKeyVault.init(
|
||||
mock<AzureKeyVaultContext>({
|
||||
settings: {
|
||||
vaultName: 'my-vault',
|
||||
tenantId: 'my-tenant-id',
|
||||
clientId: 'my-client-id',
|
||||
clientSecret: 'my-client-secret',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await azureKeyVault.init(createSettingsContext(baseSettings));
|
||||
|
||||
vi.spyOn(SecretClient.prototype, 'listPropertiesOfSecrets').mockImplementation(
|
||||
() =>
|
||||
|
||||
+113
-4
@@ -5,7 +5,7 @@ import { Container } from '@n8n/di';
|
||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||
import { type INodeProperties, UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import type { AzureKeyVaultContext } from './types';
|
||||
import type { AzureKeyVaultContext, AzureKeyVaultEnvironment } from './types';
|
||||
import { DOCS_HELP_NOTICE } from '../../constants';
|
||||
import {
|
||||
buildFailureSummaryLogContext,
|
||||
@@ -22,6 +22,26 @@ type AzureHttpLikeError = Error & {
|
||||
code?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_AUTHORITY_HOST = 'https://login.microsoftonline.com';
|
||||
|
||||
const AZURE_CLOUD_ENDPOINTS: Record<
|
||||
Exclude<AzureKeyVaultEnvironment, 'custom'>,
|
||||
{ vaultSuffix: string; authorityHost: string }
|
||||
> = {
|
||||
public: {
|
||||
vaultSuffix: 'vault.azure.net',
|
||||
authorityHost: DEFAULT_AUTHORITY_HOST,
|
||||
},
|
||||
usGovernment: {
|
||||
vaultSuffix: 'vault.usgovcloudapi.net',
|
||||
authorityHost: 'https://login.microsoftonline.us',
|
||||
},
|
||||
china: {
|
||||
vaultSuffix: 'vault.azure.cn',
|
||||
authorityHost: 'https://login.partner.microsoftonline.cn',
|
||||
},
|
||||
};
|
||||
|
||||
export class AzureKeyVault extends SecretsProvider {
|
||||
name = 'azureKeyVault';
|
||||
|
||||
@@ -29,6 +49,40 @@ export class AzureKeyVault extends SecretsProvider {
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
DOCS_HELP_NOTICE,
|
||||
{
|
||||
displayName: 'Azure Cloud',
|
||||
name: 'environment',
|
||||
hint: 'The Azure cloud environment your Key Vault is hosted in.',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Azure Public Cloud',
|
||||
value: 'public',
|
||||
description:
|
||||
'Uses <code>vault.azure.net</code> and <code>login.microsoftonline.com</code>',
|
||||
},
|
||||
{
|
||||
name: 'Azure US Government',
|
||||
value: 'usGovernment',
|
||||
description:
|
||||
'Uses <code>vault.usgovcloudapi.net</code> and <code>login.microsoftonline.us</code>',
|
||||
},
|
||||
{
|
||||
name: 'Azure China',
|
||||
value: 'china',
|
||||
description:
|
||||
'Uses <code>vault.azure.cn</code> and <code>login.partner.microsoftonline.cn</code>',
|
||||
},
|
||||
{
|
||||
name: 'Custom',
|
||||
value: 'custom',
|
||||
description:
|
||||
'Provide the vault URL and authority host directly, for setups such as Azure Stack or proxied environments',
|
||||
},
|
||||
],
|
||||
default: 'public',
|
||||
noDataExpression: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Vault Name',
|
||||
hint: 'The name of your existing Azure Key Vault.',
|
||||
@@ -38,6 +92,26 @@ export class AzureKeyVault extends SecretsProvider {
|
||||
required: true,
|
||||
placeholder: 'e.g. my-vault',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
environment: ['custom'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Vault URL',
|
||||
hint: 'The full URL of your existing Azure Key Vault.',
|
||||
name: 'vaultUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'e.g. https://my-vault.vault.usgovcloudapi.net',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
environment: ['custom'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Tenant ID',
|
||||
@@ -70,6 +144,20 @@ export class AzureKeyVault extends SecretsProvider {
|
||||
typeOptions: { password: true },
|
||||
noDataExpression: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Authority Host',
|
||||
hint: 'The Microsoft Entra authority to authenticate against. Leave empty to use the default (https://login.microsoftonline.com).',
|
||||
name: 'authorityHost',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. https://login.microsoftonline.us',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
environment: ['custom'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
private cachedSecrets: Record<string, string> = {};
|
||||
@@ -91,14 +179,17 @@ export class AzureKeyVault extends SecretsProvider {
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
try {
|
||||
const { vaultName, tenantId, clientId, clientSecret } = this.settings;
|
||||
const { tenantId, clientId, clientSecret } = this.settings;
|
||||
const { vaultUrl, authorityHost } = this.resolveEndpoints();
|
||||
|
||||
const { ClientSecretCredential } = await import('@azure/identity');
|
||||
const { SecretClient } = await import('@azure/keyvault-secrets');
|
||||
|
||||
// TODO: Not routed through OutboundHttp for now. It would require `@azure/core-rest-pipeline`, which is not worth it just to share agents.
|
||||
const credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
|
||||
this.client = new SecretClient(`https://${vaultName}.vault.azure.net/`, credential);
|
||||
const credential = new ClientSecretCredential(tenantId, clientId, clientSecret, {
|
||||
authorityHost,
|
||||
});
|
||||
this.client = new SecretClient(vaultUrl, credential);
|
||||
|
||||
this.logger.debug('Azure Key Vault provider connected');
|
||||
} catch (error) {
|
||||
@@ -111,6 +202,24 @@ export class AzureKeyVault extends SecretsProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveEndpoints(): { vaultUrl: string; authorityHost: string } {
|
||||
const { environment = 'public', vaultName, vaultUrl, authorityHost } = this.settings;
|
||||
|
||||
if (environment === 'custom') {
|
||||
const trimmedAuthorityHost = authorityHost?.trim();
|
||||
return {
|
||||
vaultUrl: (vaultUrl ?? '').trim(),
|
||||
authorityHost: trimmedAuthorityHost ? trimmedAuthorityHost : DEFAULT_AUTHORITY_HOST,
|
||||
};
|
||||
}
|
||||
|
||||
const cloudEndpoints = AZURE_CLOUD_ENDPOINTS[environment];
|
||||
return {
|
||||
vaultUrl: `https://${vaultName}.${cloudEndpoints.vaultSuffix}/`,
|
||||
authorityHost: cloudEndpoints.authorityHost,
|
||||
};
|
||||
}
|
||||
|
||||
async test(): Promise<[boolean] | [boolean, string]> {
|
||||
if (!this.client) return [false, 'Failed to connect to Azure Key Vault'];
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { SecretsProviderSettings } from '../../types';
|
||||
|
||||
export type AzureKeyVaultEnvironment = 'public' | 'usGovernment' | 'china' | 'custom';
|
||||
|
||||
export type AzureKeyVaultContext = SecretsProviderSettings<{
|
||||
vaultName: string;
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
environment?: AzureKeyVaultEnvironment;
|
||||
vaultUrl?: string;
|
||||
authorityHost?: string;
|
||||
}>;
|
||||
|
||||
+3
@@ -29,6 +29,9 @@ const defaultProviderData: Record<string, Partial<ExternalSecretsProviderData>>
|
||||
infisical: {
|
||||
siteURL: 'https://app.infisical.com',
|
||||
},
|
||||
azureKeyVault: {
|
||||
environment: 'public',
|
||||
},
|
||||
};
|
||||
|
||||
const externalSecretsStore = useExternalSecretsStore();
|
||||
|
||||
Reference in New Issue
Block a user