mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 03:24:59 +02:00
fix(core): Improve external secrets provider failure logs (#33104)
This commit is contained in:
parent
ddfbb77935
commit
e01bb9e8db
|
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
buildHttpProviderErrorContext,
|
||||
buildFailureSummaryLogContext,
|
||||
} from '../secrets-provider-errors';
|
||||
|
||||
describe('buildHttpProviderErrorContext', () => {
|
||||
it('extracts statusCode without duplicating it in errorCode', () => {
|
||||
const error = Object.assign(new Error('Request failed'), {
|
||||
response: { status: 403 },
|
||||
});
|
||||
|
||||
expect(buildHttpProviderErrorContext(error)).toEqual({
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts transport and SDK codes', () => {
|
||||
const error = Object.assign(new Error('Forbidden'), {
|
||||
response: { status: 403 },
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
|
||||
expect(buildHttpProviderErrorContext(error)).toEqual({
|
||||
statusCode: 403,
|
||||
errorCode: 'FORBIDDEN',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFailureSummaryLogContext', () => {
|
||||
it('returns null when there are no failures', () => {
|
||||
expect(buildFailureSummaryLogContext([])).toBeNull();
|
||||
});
|
||||
|
||||
it('summarizes failures with capped sample names', () => {
|
||||
expect(
|
||||
buildFailureSummaryLogContext([
|
||||
{ name: 'secret-a', errorCode: 5 },
|
||||
{ name: 'secret-b', errorCode: 5 },
|
||||
{ name: 'secret-c', errorCode: 7 },
|
||||
]),
|
||||
).toEqual({
|
||||
failedCount: 3,
|
||||
errorCodes: { '5': 2, '7': 1 },
|
||||
sampleSecretNames: ['secret-a', 'secret-b', 'secret-c'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import type { Logger } from '@n8n/backend-common';
|
||||
import { httpStatusFromError, isConnectionRefusedError } from '@n8n/backend-network';
|
||||
|
||||
export type SafeContextValue = string | number | boolean | undefined;
|
||||
type AggregateContextValue = Record<string, number> | string[];
|
||||
type LogContextValue = SafeContextValue | AggregateContextValue;
|
||||
export type LogContext = Record<string, LogContextValue>;
|
||||
|
||||
export type SecretsProviderOperation =
|
||||
| 'initialize'
|
||||
| 'connect'
|
||||
| 'disconnect'
|
||||
| 'update'
|
||||
| 'test'
|
||||
| 'tokenRefresh';
|
||||
|
||||
export type HttpProviderErrorLogContext = LogContext & {
|
||||
errorCode?: SafeContextValue;
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
export const UPDATE_FAILURE_SAMPLE_SIZE = 5;
|
||||
|
||||
export function buildHttpProviderErrorContext(error: unknown): HttpProviderErrorLogContext {
|
||||
const context: HttpProviderErrorLogContext = {};
|
||||
|
||||
if (isConnectionRefusedError(error)) {
|
||||
context.errorCode = 'ECONNREFUSED';
|
||||
}
|
||||
|
||||
const statusCode = httpStatusFromError(error);
|
||||
if (statusCode !== undefined) {
|
||||
context.statusCode = statusCode;
|
||||
}
|
||||
|
||||
if (
|
||||
context.errorCode === undefined &&
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error
|
||||
) {
|
||||
const { code } = error;
|
||||
if ((typeof code === 'string' || typeof code === 'number') && code !== statusCode) {
|
||||
context.errorCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
context.errorCode === undefined &&
|
||||
context.statusCode === undefined &&
|
||||
error instanceof Error
|
||||
) {
|
||||
context.errorCode = error.name;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function buildFailureSummaryLogContext(
|
||||
failures: Array<{ name: string; errorCode: SafeContextValue }>,
|
||||
): {
|
||||
failedCount: number;
|
||||
errorCodes: Record<string, number>;
|
||||
sampleSecretNames: string[];
|
||||
} | null {
|
||||
if (failures.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorCodes: Record<string, number> = {};
|
||||
for (const failure of failures) {
|
||||
const key = String(failure.errorCode ?? 'unknown');
|
||||
errorCodes[key] = (errorCodes[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
failedCount: failures.length,
|
||||
errorCodes,
|
||||
sampleSecretNames: failures.slice(0, UPDATE_FAILURE_SAMPLE_SIZE).map((failure) => failure.name),
|
||||
};
|
||||
}
|
||||
|
||||
type LogSecretsProviderOperationFailureParams = {
|
||||
logger: Logger;
|
||||
message: string;
|
||||
providerName: string;
|
||||
providerDisplayName: string;
|
||||
} & SecretsProviderOperationFailureParams;
|
||||
|
||||
export type SecretsProviderOperationFailureParams = {
|
||||
operation: SecretsProviderOperation;
|
||||
error: unknown;
|
||||
context?: LogContext;
|
||||
};
|
||||
|
||||
export function logSecretsProviderOperationFailure({
|
||||
logger,
|
||||
message,
|
||||
providerName,
|
||||
providerDisplayName,
|
||||
operation,
|
||||
error,
|
||||
context = {},
|
||||
}: LogSecretsProviderOperationFailureParams): void {
|
||||
logger.warn(message, {
|
||||
providerName,
|
||||
providerDisplayName,
|
||||
operation,
|
||||
errorName: error instanceof Error ? error.name : undefined,
|
||||
...context,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Mock } from 'vitest';
|
||||
import { SecretsManager } from '@aws-sdk/client-secrets-manager';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { OutboundHttp, HttpTransport } from '@n8n/backend-network';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import type { Agent as HttpAgent } from 'node:http';
|
||||
|
|
@ -9,6 +10,19 @@ import { AwsSecretsManager, type AwsSecretsManagerContext } from '../aws-secrets
|
|||
|
||||
vi.mock('@aws-sdk/client-secrets-manager');
|
||||
|
||||
function createAwsSdkError(
|
||||
name: string,
|
||||
options: { httpStatusCode?: number; Code?: string; code?: string | number } = {},
|
||||
): Error {
|
||||
return Object.assign(new Error(name), {
|
||||
name,
|
||||
...(options.Code !== undefined ? { Code: options.Code } : {}),
|
||||
...(options.code !== undefined ? { code: options.code } : {}),
|
||||
$metadata:
|
||||
options.httpStatusCode !== undefined ? { httpStatusCode: options.httpStatusCode } : {},
|
||||
});
|
||||
}
|
||||
|
||||
describe('AwsSecretsManager', () => {
|
||||
const region = 'eu-central-1';
|
||||
const accessKeyId = 'FAKE-ACCESS-KEY-ID';
|
||||
|
|
@ -17,13 +31,60 @@ describe('AwsSecretsManager', () => {
|
|||
const context = mock<AwsSecretsManagerContext>();
|
||||
const listSecretsSpy = vi.spyOn(SecretsManager.prototype, 'listSecrets');
|
||||
const batchGetSpy = vi.spyOn(SecretsManager.prototype, 'batchGetSecretValue');
|
||||
const logger = mock<Logger>();
|
||||
|
||||
let awsSecretsManager: AwsSecretsManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
|
||||
awsSecretsManager = new AwsSecretsManager();
|
||||
awsSecretsManager = new AwsSecretsManager(logger);
|
||||
});
|
||||
|
||||
describe('error context', () => {
|
||||
it('extracts legacy Code property', () => {
|
||||
const error = Object.assign(new Error('Access denied'), { Code: 'AccessDenied' });
|
||||
|
||||
expect(awsSecretsManager['getAwsErrorCode'](error)).toBe('AccessDenied');
|
||||
expect(awsSecretsManager['awsErrorContext'](error)).toEqual({ errorCode: 'AccessDenied' });
|
||||
});
|
||||
|
||||
it('extracts string and numeric error codes from code property', () => {
|
||||
expect(
|
||||
awsSecretsManager['getAwsErrorCode'](
|
||||
Object.assign(new Error('Connection failed'), { code: 'ECONNREFUSED' }),
|
||||
),
|
||||
).toBe('ECONNREFUSED');
|
||||
expect(
|
||||
awsSecretsManager['getAwsErrorCode'](Object.assign(new Error('Timeout'), { code: 408 })),
|
||||
).toBe(408);
|
||||
});
|
||||
|
||||
it('extracts AWS SDK exception name and HTTP status code', () => {
|
||||
expect(
|
||||
awsSecretsManager['awsErrorContext'](
|
||||
createAwsSdkError('AccessDeniedException', { httpStatusCode: 403 }),
|
||||
),
|
||||
).toEqual({
|
||||
errorCode: 'AccessDeniedException',
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to Error.name for generic errors', () => {
|
||||
expect(awsSecretsManager['getAwsErrorCode'](new Error('Something went wrong'))).toBe('Error');
|
||||
expect(awsSecretsManager['awsErrorContext'](new Error('Something went wrong'))).toEqual({
|
||||
errorCode: 'Error',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty context for non-error values', () => {
|
||||
expect(awsSecretsManager['getAwsErrorCode']('not an error')).toBeUndefined();
|
||||
expect(awsSecretsManager['getAwsErrorCode'](null)).toBeUndefined();
|
||||
expect(awsSecretsManager['awsErrorContext']('not an error')).toEqual({});
|
||||
expect(awsSecretsManager['awsErrorContext'](null)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transport wiring', () => {
|
||||
|
|
@ -66,12 +127,29 @@ describe('AwsSecretsManager', () => {
|
|||
await awsSecretsManager.init(context);
|
||||
|
||||
listSecretsSpy.mockImplementation(() => {
|
||||
throw new Error('Invalid credentials');
|
||||
throw createAwsSdkError('AccessDeniedException', {
|
||||
httpStatusCode: 403,
|
||||
Code: 'AccessDenied',
|
||||
});
|
||||
});
|
||||
|
||||
await awsSecretsManager.connect();
|
||||
|
||||
expect(awsSecretsManager.state).toBe('error');
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to connect AWS Secrets Manager provider',
|
||||
expect.objectContaining({
|
||||
providerName: 'awsSecretsManager',
|
||||
providerDisplayName: 'AWS Secrets Manager',
|
||||
operation: 'connect',
|
||||
region,
|
||||
authMethod: 'iamUser',
|
||||
errorName: 'AccessDeniedException',
|
||||
errorCode: 'AccessDenied',
|
||||
statusCode: 403,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -197,4 +275,33 @@ describe('AwsSecretsManager', () => {
|
|||
expect(awsSecretsManager.getSecret('secret2')).toBe('secret2-value');
|
||||
expect(awsSecretsManager.getSecret('secret3')).toBe('secret3-value');
|
||||
});
|
||||
|
||||
it('should log and rethrow update failures', async () => {
|
||||
context.settings = {
|
||||
region,
|
||||
authMethod: 'iamUser',
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
};
|
||||
await awsSecretsManager.init(context);
|
||||
|
||||
listSecretsSpy.mockImplementation(() => {
|
||||
throw new Error('Failed to list secrets');
|
||||
});
|
||||
|
||||
await expect(awsSecretsManager.update()).rejects.toThrow('Failed to list secrets');
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to update AWS Secrets Manager provider secrets',
|
||||
expect.objectContaining({
|
||||
providerName: 'awsSecretsManager',
|
||||
providerDisplayName: 'AWS Secrets Manager',
|
||||
operation: 'update',
|
||||
region,
|
||||
authMethod: 'iamUser',
|
||||
errorName: expect.any(String),
|
||||
errorCode: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { AuthenticationError } from '@azure/identity';
|
||||
import { SecretClient } from '@azure/keyvault-secrets';
|
||||
import type { KeyVaultSecret } from '@azure/keyvault-secrets';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { AzureKeyVault } from '../azure-key-vault/azure-key-vault';
|
||||
|
|
@ -9,11 +12,153 @@ import type { AzureKeyVaultContext } from '../azure-key-vault/types';
|
|||
vi.mock('@azure/identity');
|
||||
vi.mock('@azure/keyvault-secrets');
|
||||
|
||||
describe('AzureKeyVault', () => {
|
||||
const azureKeyVault = new AzureKeyVault();
|
||||
function createRestErrorLike(
|
||||
message: string,
|
||||
{ statusCode, code }: { statusCode?: number; code?: string },
|
||||
): Error {
|
||||
return Object.assign(new Error(message), {
|
||||
name: 'RestError',
|
||||
statusCode,
|
||||
code,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
describe('AzureKeyVault', () => {
|
||||
const logger = mock<Logger>();
|
||||
let azureKeyVault: AzureKeyVault;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
azureKeyVault = new AzureKeyVault(logger);
|
||||
});
|
||||
|
||||
describe('error context', () => {
|
||||
it('extracts statusCode and errorCode from RestError-like errors', () => {
|
||||
const error = createRestErrorLike('Permission denied', {
|
||||
statusCode: 403,
|
||||
code: 'Forbidden',
|
||||
});
|
||||
|
||||
expect(azureKeyVault['azureErrorContext'](error)).toEqual({
|
||||
statusCode: 403,
|
||||
errorCode: 'Forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts errorCode from RestError-like errors without statusCode', () => {
|
||||
const error = createRestErrorLike('Connection failed', {
|
||||
code: 'REQUEST_SEND_ERROR',
|
||||
});
|
||||
|
||||
expect(azureKeyVault['azureErrorContext'](error)).toEqual({
|
||||
errorCode: 'REQUEST_SEND_ERROR',
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts statusCode and errorCode from AuthenticationError', () => {
|
||||
const error = Object.assign(
|
||||
new AuthenticationError(401, {
|
||||
error: 'invalid_client',
|
||||
error_description: 'Invalid client secret',
|
||||
}),
|
||||
{
|
||||
statusCode: 401,
|
||||
errorResponse: { error: 'invalid_client' },
|
||||
},
|
||||
);
|
||||
|
||||
expect(azureKeyVault['azureErrorContext'](error)).toEqual({
|
||||
statusCode: 401,
|
||||
errorCode: 'invalid_client',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to Error.name for generic errors', () => {
|
||||
expect(azureKeyVault['azureErrorContext'](new Error('Something went wrong'))).toEqual({
|
||||
errorCode: 'Error',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty context for non-error values', () => {
|
||||
expect(azureKeyVault['azureErrorContext']('not an error')).toEqual({});
|
||||
expect(azureKeyVault['azureErrorContext'](null)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const setupError = new Error('Invalid configuration');
|
||||
const SecretClientMock = SecretClient as unknown as Mock;
|
||||
SecretClientMock.mockImplementationOnce(() => {
|
||||
throw setupError;
|
||||
});
|
||||
|
||||
await azureKeyVault.connect();
|
||||
|
||||
expect(azureKeyVault.state).toBe('error');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to connect Azure Key Vault provider',
|
||||
expect.objectContaining({
|
||||
providerName: 'azureKeyVault',
|
||||
providerDisplayName: 'Azure Key Vault',
|
||||
operation: 'connect',
|
||||
vaultName: 'my-vault',
|
||||
errorName: expect.any(String),
|
||||
errorCode: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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.connect();
|
||||
|
||||
const restError = Object.assign(new Error('Permission denied'), {
|
||||
name: 'RestError',
|
||||
statusCode: 403,
|
||||
code: 'Forbidden',
|
||||
});
|
||||
|
||||
vi.spyOn(SecretClient.prototype, 'listPropertiesOfSecrets').mockReturnValue({
|
||||
next: vi.fn().mockRejectedValue(restError),
|
||||
} as never);
|
||||
|
||||
const [success, message] = await azureKeyVault.test();
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(message).toBe('Permission denied');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Azure Key Vault provider test failed',
|
||||
expect.objectContaining({
|
||||
providerName: 'azureKeyVault',
|
||||
operation: 'test',
|
||||
errorName: 'RestError',
|
||||
statusCode: 403,
|
||||
errorCode: 'Forbidden',
|
||||
vaultName: 'my-vault',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update cached secrets', async () => {
|
||||
|
|
@ -137,6 +282,25 @@ describe('AzureKeyVault', () => {
|
|||
|
||||
expect(azureKeyVault.getSecret('good')).toBe('fine');
|
||||
expect(azureKeyVault.hasSecret('bad')).toBe(false);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Could not read Azure Key Vault secret "bad"',
|
||||
expect.objectContaining({
|
||||
providerName: 'azureKeyVault',
|
||||
operation: 'update',
|
||||
secretName: 'bad',
|
||||
vaultName: 'my-vault',
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Skipped unreadable Azure Key Vault secrets during update',
|
||||
expect.objectContaining({
|
||||
providerName: 'azureKeyVault',
|
||||
operation: 'update',
|
||||
vaultName: 'my-vault',
|
||||
failedCount: 1,
|
||||
sampleSecretNames: ['bad'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when every getSecret fails and leave the previous cache unchanged', async () => {
|
||||
|
|
@ -185,6 +349,21 @@ describe('AzureKeyVault', () => {
|
|||
expect(thrown.message).toBe('Could not read any secrets from Azure Key Vault');
|
||||
expect(thrown.cause).toEqual(expect.objectContaining({ message: 'Key Vault unavailable' }));
|
||||
}
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Could not read any secrets from Azure Key Vault',
|
||||
expect.objectContaining({
|
||||
providerName: 'azureKeyVault',
|
||||
operation: 'update',
|
||||
vaultName: 'my-vault',
|
||||
failedCount: 1,
|
||||
sampleSecretNames: ['only-secret'],
|
||||
errorName: 'Error',
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||
'Skipped unreadable Azure Key Vault secrets during update',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(azureKeyVault.getSecret('only-secret')).toBe('cached-value');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
|
||||
import type { google } from '@google-cloud/secret-manager/build/protos/protos';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { GcpSecretsManager } from '../gcp-secrets-manager/gcp-secrets-manager';
|
||||
|
|
@ -18,11 +20,42 @@ const VALID_SERVICE_ACCOUNT_KEY = (projectId: string) =>
|
|||
'-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC\n-----END PRIVATE KEY-----',
|
||||
});
|
||||
|
||||
describe('GCP Secrets Manager', () => {
|
||||
const gcpSecretsManager = new GcpSecretsManager();
|
||||
function createGrpcError(message: string, code: number): Error {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
describe('GCP Secrets Manager', () => {
|
||||
const logger = mock<Logger>();
|
||||
let gcpSecretsManager: GcpSecretsManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
gcpSecretsManager = new GcpSecretsManager(logger);
|
||||
});
|
||||
|
||||
describe('error codes', () => {
|
||||
it('extracts numeric gRPC status codes', () => {
|
||||
expect(gcpSecretsManager['getGcpErrorCode'](createGrpcError('NOT_FOUND', 5))).toBe(5);
|
||||
expect(gcpSecretsManager['getGcpErrorCode'](createGrpcError('PERMISSION_DENIED', 7))).toBe(7);
|
||||
});
|
||||
|
||||
it('extracts string error codes', () => {
|
||||
const error = Object.assign(new Error('Connection failed'), { code: 'ECONNREFUSED' });
|
||||
|
||||
expect(gcpSecretsManager['getGcpErrorCode'](error)).toBe('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('returns undefined for generic errors without a code', () => {
|
||||
expect(
|
||||
gcpSecretsManager['getGcpErrorCode'](new Error('Something went wrong')),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for non-error values', () => {
|
||||
expect(gcpSecretsManager['getGcpErrorCode']('not an error')).toBeUndefined();
|
||||
expect(gcpSecretsManager['getGcpErrorCode'](null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('init validation', () => {
|
||||
|
|
@ -45,6 +78,15 @@ describe('GCP Secrets Manager', () => {
|
|||
await expect(
|
||||
gcpSecretsManager.init(mock<GcpSecretsManagerContext>({ settings })),
|
||||
).rejects.toThrow(UserError);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to initialize GCP Secrets Manager provider',
|
||||
expect.objectContaining({
|
||||
providerName: 'gcpSecretsManager',
|
||||
providerDisplayName: 'GCP Secrets Manager',
|
||||
operation: 'initialize',
|
||||
errorName: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UserError when JSON lacks client_email', async () => {
|
||||
|
|
@ -75,6 +117,36 @@ describe('GCP Secrets Manager', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should log failed client setup while preserving error state', async () => {
|
||||
const PROJECT_ID = 'my-project-id';
|
||||
|
||||
await gcpSecretsManager.init(
|
||||
mock<GcpSecretsManagerContext>({
|
||||
settings: { serviceAccountKey: VALID_SERVICE_ACCOUNT_KEY(PROJECT_ID) },
|
||||
}),
|
||||
);
|
||||
|
||||
const setupError = new Error('Invalid configuration');
|
||||
const SecretManagerServiceClientMock = SecretManagerServiceClient as unknown as Mock;
|
||||
SecretManagerServiceClientMock.mockImplementationOnce(() => {
|
||||
throw setupError;
|
||||
});
|
||||
|
||||
await gcpSecretsManager.connect();
|
||||
|
||||
expect(gcpSecretsManager.state).toBe('error');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to connect GCP Secrets Manager provider',
|
||||
expect.objectContaining({
|
||||
providerName: 'gcpSecretsManager',
|
||||
providerDisplayName: 'GCP Secrets Manager',
|
||||
operation: 'connect',
|
||||
projectId: PROJECT_ID,
|
||||
errorName: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update cached secrets', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
|
|
@ -139,6 +211,33 @@ describe('GCP Secrets Manager', () => {
|
|||
expect(gcpSecretsManager.getSecret('secret3')).toBeUndefined(); // no value
|
||||
});
|
||||
|
||||
it('should log failed connection tests while preserving the result', async () => {
|
||||
const PROJECT_ID = 'my-project-id';
|
||||
|
||||
await gcpSecretsManager.init(
|
||||
mock<GcpSecretsManagerContext>({
|
||||
settings: { serviceAccountKey: VALID_SERVICE_ACCOUNT_KEY(PROJECT_ID) },
|
||||
}),
|
||||
);
|
||||
await gcpSecretsManager.connect();
|
||||
|
||||
vi.spyOn(SecretManagerServiceClient.prototype, 'initialize').mockRejectedValue(
|
||||
new Error('Invalid credentials'),
|
||||
);
|
||||
|
||||
await expect(gcpSecretsManager.test()).resolves.toEqual([false, 'Invalid credentials']);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'GCP Secrets Manager provider test failed',
|
||||
expect.objectContaining({
|
||||
providerName: 'gcpSecretsManager',
|
||||
providerDisplayName: 'GCP Secrets Manager',
|
||||
operation: 'test',
|
||||
errorName: 'Error',
|
||||
projectId: PROJECT_ID,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error when accessing secret versions', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
|
|
@ -178,16 +277,19 @@ describe('GCP Secrets Manager', () => {
|
|||
] as GcpSecretVersionResponse[];
|
||||
});
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
try {
|
||||
await gcpSecretsManager.connect();
|
||||
await gcpSecretsManager.update();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.message).toBe('test error');
|
||||
}
|
||||
await gcpSecretsManager.connect();
|
||||
|
||||
await expect(gcpSecretsManager.update()).rejects.toThrow('test error');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to update GCP Secrets Manager provider secrets',
|
||||
expect.objectContaining({
|
||||
providerName: 'gcpSecretsManager',
|
||||
providerDisplayName: 'GCP Secrets Manager',
|
||||
operation: 'update',
|
||||
errorName: 'Error',
|
||||
projectId: PROJECT_ID,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors when accessing secret versions (NOT_FOUND)', async () => {
|
||||
|
|
@ -256,6 +358,26 @@ describe('GCP Secrets Manager', () => {
|
|||
expect(gcpSecretsManager.getSecret('secret1')).toBeUndefined(); // error case
|
||||
expect(gcpSecretsManager.getSecret('secret2')).toBe('value2');
|
||||
expect(gcpSecretsManager.getSecret('secret3')).toBeUndefined(); // no value
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Skipping inaccessible GCP secret version',
|
||||
expect.objectContaining({
|
||||
providerName: 'gcpSecretsManager',
|
||||
operation: 'update',
|
||||
secretName: 'secret1',
|
||||
errorCode: 5,
|
||||
projectId: PROJECT_ID,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Skipped inaccessible GCP secret versions during update',
|
||||
expect.objectContaining({
|
||||
providerName: 'gcpSecretsManager',
|
||||
operation: 'update',
|
||||
projectId: PROJECT_ID,
|
||||
failedCount: 1,
|
||||
sampleSecretNames: ['secret1'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors when accessing secret versions (PERMISSION_DENIED)', async () => {
|
||||
|
|
@ -324,6 +446,26 @@ describe('GCP Secrets Manager', () => {
|
|||
expect(gcpSecretsManager.getSecret('secret1')).toBeUndefined(); // error case
|
||||
expect(gcpSecretsManager.getSecret('secret2')).toBe('value2');
|
||||
expect(gcpSecretsManager.getSecret('secret3')).toBeUndefined(); // no value
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Skipping inaccessible GCP secret version',
|
||||
expect.objectContaining({
|
||||
providerName: 'gcpSecretsManager',
|
||||
operation: 'update',
|
||||
secretName: 'secret1',
|
||||
errorCode: 7,
|
||||
projectId: PROJECT_ID,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Skipped inaccessible GCP secret versions during update',
|
||||
expect.objectContaining({
|
||||
providerName: 'gcpSecretsManager',
|
||||
operation: 'update',
|
||||
projectId: PROJECT_ID,
|
||||
failedCount: 1,
|
||||
sampleSecretNames: ['secret1'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors when accessing secret versions (UNAVAILABLE)', async () => {
|
||||
|
|
@ -392,5 +534,25 @@ describe('GCP Secrets Manager', () => {
|
|||
expect(gcpSecretsManager.getSecret('secret1')).toBeUndefined(); // error case
|
||||
expect(gcpSecretsManager.getSecret('secret2')).toBe('value2');
|
||||
expect(gcpSecretsManager.getSecret('secret3')).toBeUndefined(); // no value
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Skipping inaccessible GCP secret version',
|
||||
expect.objectContaining({
|
||||
providerName: 'gcpSecretsManager',
|
||||
operation: 'update',
|
||||
secretName: 'secret1',
|
||||
errorCode: 14,
|
||||
projectId: PROJECT_ID,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Skipped inaccessible GCP secret versions during update',
|
||||
expect.objectContaining({
|
||||
providerName: 'gcpSecretsManager',
|
||||
operation: 'update',
|
||||
projectId: PROJECT_ID,
|
||||
failedCount: 1,
|
||||
sampleSecretNames: ['secret1'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,10 +60,33 @@ const WORKSPACE_PATH = `/api/v1/workspace/${PROJECT_ID}`;
|
|||
const LOGIN_PATH = '/api/v1/auth/universal-auth/login';
|
||||
const SECRETS_PATH = '/api/v4/secrets';
|
||||
|
||||
const infisicalConnectSettingsLogContext = {
|
||||
siteURL: SITE_URL,
|
||||
projectId: PROJECT_ID,
|
||||
authMethod: 'universalAuth',
|
||||
};
|
||||
|
||||
const infisicalTestSettingsLogContext = {
|
||||
siteURL: SITE_URL,
|
||||
projectId: PROJECT_ID,
|
||||
};
|
||||
|
||||
const infisicalUpdateSettingsLogContext = {
|
||||
siteURL: SITE_URL,
|
||||
projectId: PROJECT_ID,
|
||||
environment: ENVIRONMENT,
|
||||
secretPath: SECRET_PATH,
|
||||
};
|
||||
|
||||
describe('InfisicalProvider', () => {
|
||||
const logger = mockInstance(Logger);
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
});
|
||||
|
||||
function createProvider(routes: Route[]) {
|
||||
const { outboundHttp, httpRequest, requests } = createFakeOutboundHttp(
|
||||
routes,
|
||||
|
|
@ -191,6 +214,17 @@ describe('InfisicalProvider', () => {
|
|||
const [success, message] = await provider.test();
|
||||
expect(success).toBe(false);
|
||||
expect(message).toBe('Connection refused. Check the Site URL.');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Infisical provider test failed',
|
||||
expect.objectContaining({
|
||||
providerName: 'infisical',
|
||||
providerDisplayName: 'Infisical',
|
||||
...infisicalTestSettingsLogContext,
|
||||
operation: 'test',
|
||||
endpoint: 'workspace',
|
||||
errorCode: 'ECONNREFUSED',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -203,6 +237,16 @@ describe('InfisicalProvider', () => {
|
|||
await provider.connect();
|
||||
|
||||
expect(provider.state).toBe('error');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to connect Infisical provider',
|
||||
expect.objectContaining({
|
||||
providerName: 'infisical',
|
||||
providerDisplayName: 'Infisical',
|
||||
...infisicalConnectSettingsLogContext,
|
||||
operation: 'connect',
|
||||
statusCode: 401,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -274,6 +318,47 @@ describe('InfisicalProvider', () => {
|
|||
expect(secretsCalls[1].headers).toMatchObject({ Authorization: 'Bearer refreshed-token' });
|
||||
});
|
||||
|
||||
it('logs and rethrows update failures', async () => {
|
||||
const { provider } = await connectedProvider([
|
||||
{ method: 'GET', pathname: SECRETS_PATH, status: 500, body: { message: 'Failed' } },
|
||||
]);
|
||||
|
||||
await expect(provider.update()).rejects.toThrow('Request failed with status 500');
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to update Infisical provider secrets',
|
||||
expect.objectContaining({
|
||||
providerName: 'infisical',
|
||||
providerDisplayName: 'Infisical',
|
||||
...infisicalUpdateSettingsLogContext,
|
||||
operation: 'update',
|
||||
endpoint: 'secrets',
|
||||
statusCode: 500,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs token refresh failures before attempting to reconnect', async () => {
|
||||
const { provider } = await initProvider([
|
||||
{ method: 'POST', pathname: LOGIN_PATH, networkError: 'ECONNREFUSED' },
|
||||
]);
|
||||
const connect = vi.spyOn(provider, 'connect').mockResolvedValue();
|
||||
|
||||
await (provider as unknown as { tokenRefresh: () => Promise<void> }).tokenRefresh();
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to refresh Infisical token. Attempting reconnect.',
|
||||
expect.objectContaining({
|
||||
providerName: 'infisical',
|
||||
providerDisplayName: 'Infisical',
|
||||
...infisicalConnectSettingsLogContext,
|
||||
operation: 'tokenRefresh',
|
||||
errorCode: 'ECONNREFUSED',
|
||||
}),
|
||||
);
|
||||
expect(connect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('caches secrets from imports alongside top-level secrets', async () => {
|
||||
const { provider } = await connectedProvider([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Logger } from '@n8n/backend-common';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { OnePasswordProvider } from '../one-password';
|
||||
import type { OnePasswordContext } from '../one-password';
|
||||
import { OnePasswordProvider, type OnePasswordContext } from '../one-password';
|
||||
|
||||
const mockListVaults = vi.fn();
|
||||
const mockListItems = vi.fn();
|
||||
|
|
@ -17,10 +18,14 @@ vi.mock('@1password/connect', () => ({
|
|||
}));
|
||||
|
||||
describe('OnePasswordProvider', () => {
|
||||
const provider = new OnePasswordProvider();
|
||||
const logger = mockInstance(Logger);
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
let provider: OnePasswordProvider;
|
||||
|
||||
afterEach(() => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
provider = new OnePasswordProvider(logger);
|
||||
});
|
||||
|
||||
describe('init validation', () => {
|
||||
|
|
@ -29,6 +34,15 @@ describe('OnePasswordProvider', () => {
|
|||
await expect(provider.init(mock<OnePasswordContext>({ settings }))).rejects.toThrow(
|
||||
UserError,
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to initialize 1Password provider',
|
||||
expect.objectContaining({
|
||||
providerName: 'onePassword',
|
||||
providerDisplayName: '1Password',
|
||||
operation: 'initialize',
|
||||
errorName: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UserError when serverUrl is whitespace', async () => {
|
||||
|
|
@ -80,11 +94,26 @@ describe('OnePasswordProvider', () => {
|
|||
}),
|
||||
);
|
||||
|
||||
mockListVaults.mockRejectedValue(new Error('Unauthorized'));
|
||||
mockListVaults.mockRejectedValue(
|
||||
Object.assign(new Error('Unauthorized'), {
|
||||
response: { status: 401 },
|
||||
}),
|
||||
);
|
||||
|
||||
await provider.connect();
|
||||
|
||||
expect(provider.state).toBe('error');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to connect 1Password provider',
|
||||
expect.objectContaining({
|
||||
providerName: 'onePassword',
|
||||
providerDisplayName: '1Password',
|
||||
operation: 'connect',
|
||||
errorName: 'Error',
|
||||
statusCode: 401,
|
||||
serverUrl: 'http://localhost:8080',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -115,10 +144,24 @@ describe('OnePasswordProvider', () => {
|
|||
mockListVaults.mockResolvedValue([]);
|
||||
await provider.connect();
|
||||
|
||||
mockListVaults.mockRejectedValue(new Error('Connection refused'));
|
||||
mockListVaults.mockRejectedValue(
|
||||
Object.assign(new Error('Connection refused'), { code: 'ECONNREFUSED' }),
|
||||
);
|
||||
const result = await provider.test();
|
||||
|
||||
expect(result).toEqual([false, 'Connection refused']);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'1Password provider test failed',
|
||||
expect.objectContaining({
|
||||
providerName: 'onePassword',
|
||||
providerDisplayName: '1Password',
|
||||
operation: 'test',
|
||||
errorName: 'Error',
|
||||
errorCode: 'ECONNREFUSED',
|
||||
endpoint: 'vaults',
|
||||
serverUrl: 'http://localhost:8080',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -271,6 +314,24 @@ describe('OnePasswordProvider', () => {
|
|||
|
||||
expect(provider.hasSecret('Empty Fields')).toBe(false);
|
||||
});
|
||||
|
||||
it('should log and rethrow update failures', async () => {
|
||||
mockListVaults.mockRejectedValue(new Error('Service unavailable'));
|
||||
|
||||
await expect(provider.update()).rejects.toThrow('Service unavailable');
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to update 1Password provider secrets',
|
||||
expect.objectContaining({
|
||||
providerName: 'onePassword',
|
||||
providerDisplayName: '1Password',
|
||||
operation: 'update',
|
||||
errorName: 'Error',
|
||||
endpoint: 'secrets',
|
||||
serverUrl: 'http://localhost:8080',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSecret / hasSecret / getSecretNames', () => {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,12 @@ describe('VaultProvider', () => {
|
|||
// Use preferGet so list requests are plain GETs with `?list=true`.
|
||||
mockInstance(ExternalSecretsConfig, { preferGet: true });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
mockInstance(ExternalSecretsConfig, { preferGet: true });
|
||||
});
|
||||
|
||||
function createProvider(routes: Route[], settings = vaultSettings) {
|
||||
const { outboundHttp, httpRequest, requests } = createFakeOutboundHttp(
|
||||
routes,
|
||||
|
|
@ -196,6 +202,42 @@ describe('VaultProvider', () => {
|
|||
expect(provider.state).toBe('connected');
|
||||
});
|
||||
|
||||
it('logs username/password authentication failures while preserving error state', async () => {
|
||||
const settings = {
|
||||
...vaultSettings,
|
||||
settings: {
|
||||
...vaultSettings.settings,
|
||||
authMethod: 'usernameAndPassword',
|
||||
username: 'alice',
|
||||
password: 's3cret',
|
||||
},
|
||||
};
|
||||
const { provider } = await initProvider(
|
||||
[
|
||||
{
|
||||
method: 'POST',
|
||||
pathname: '/v1/auth/userpass/login/alice',
|
||||
status: 401,
|
||||
body: { errors: [] },
|
||||
},
|
||||
],
|
||||
settings,
|
||||
);
|
||||
|
||||
await provider.connect();
|
||||
|
||||
expect(provider.state).toBe('error');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Vault provider username/password authentication failed',
|
||||
expect.objectContaining({
|
||||
operation: 'connect',
|
||||
authMethod: 'usernameAndPassword',
|
||||
providerName: 'vault',
|
||||
statusCode: 401,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the LIST verb when preferGet is disabled', async () => {
|
||||
mockInstance(ExternalSecretsConfig, { preferGet: false });
|
||||
|
||||
|
|
@ -284,6 +326,33 @@ describe('VaultProvider', () => {
|
|||
|
||||
expect(provider.hasSecret('forbidden')).toBe(false);
|
||||
expect(provider.getSecretNames()).toHaveLength(0);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Vault provider failed to list KV secrets',
|
||||
expect.objectContaining({
|
||||
operation: 'update',
|
||||
mountPath: 'forbidden/',
|
||||
kvVersion: '2',
|
||||
vaultApiPath: 'forbidden/metadata/?list=true',
|
||||
statusCode: 403,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log and rethrow full update failures', async () => {
|
||||
const { provider } = await initProvider([
|
||||
{ method: 'GET', pathname: '/v1/sys/mounts', status: 500, body: { errors: [] } },
|
||||
]);
|
||||
|
||||
await expect(provider.update()).rejects.toThrow('Request failed with status 500');
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to update Vault provider secrets',
|
||||
expect.objectContaining({
|
||||
operation: 'update',
|
||||
providerName: 'vault',
|
||||
statusCode: 500,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -432,6 +501,53 @@ describe('VaultProvider', () => {
|
|||
expect(message).toBe(
|
||||
'Connection refused. Please check the host and port of the server are correct.',
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Vault provider test failed',
|
||||
expect.objectContaining({
|
||||
operation: 'test',
|
||||
vaultApiPath: 'auth/token/lookup-self',
|
||||
errorCode: 'ECONNREFUSED',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs connect failures with the connection test failure message', async () => {
|
||||
const { provider } = await initProvider([
|
||||
{ method: 'GET', pathname: '/v1/auth/token/lookup-self', status: 404, body: {} },
|
||||
]);
|
||||
|
||||
await provider.connect();
|
||||
|
||||
expect(provider.state).toBe('error');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to connect Vault provider',
|
||||
expect.objectContaining({
|
||||
operation: 'connect',
|
||||
authMethod: 'token',
|
||||
providerName: 'vault',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection logging', () => {
|
||||
it('logs token refresh failures before attempting to reconnect', async () => {
|
||||
const { provider } = await initProvider([
|
||||
{ method: 'POST', pathname: '/v1/auth/token/renew-self', networkError: 'ECONNREFUSED' },
|
||||
]);
|
||||
const connect = vi.spyOn(provider, 'connect').mockResolvedValue();
|
||||
|
||||
await (provider as unknown as { tokenRefresh: () => Promise<void> }).tokenRefresh();
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to renew Vault token. Attempting to reconnect.',
|
||||
expect.objectContaining({
|
||||
operation: 'tokenRefresh',
|
||||
authMethod: 'token',
|
||||
errorCode: 'ECONNREFUSED',
|
||||
}),
|
||||
);
|
||||
expect(connect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,18 +2,27 @@ import type { SecretsManager, SecretsManagerClientConfig } from '@aws-sdk/client
|
|||
import { Logger } from '@n8n/backend-common';
|
||||
import { OutboundHttp } from '@n8n/backend-network';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
import { type INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { DOCS_HELP_NOTICE } from '../constants';
|
||||
import {
|
||||
logSecretsProviderOperationFailure,
|
||||
type LogContext,
|
||||
type SecretsProviderOperationFailureParams,
|
||||
} from '../errors/secrets-provider-errors';
|
||||
import { UnknownAuthTypeError } from '../errors/unknown-auth-type.error';
|
||||
import { SecretsProvider } from '../types';
|
||||
import type { SecretsProviderSettings } from '../types';
|
||||
import { SecretsProvider, type SecretsProviderSettings } from '../types';
|
||||
|
||||
type Secret = {
|
||||
export type Secret = {
|
||||
secretName: string;
|
||||
secretValue: string;
|
||||
};
|
||||
|
||||
export type AwsSecretsManagerSettings = {
|
||||
region: string;
|
||||
authMethod: 'iamUser' | 'autoDetect';
|
||||
};
|
||||
|
||||
export type AwsSecretsManagerContext = SecretsProviderSettings<
|
||||
{
|
||||
region: string;
|
||||
|
|
@ -100,6 +109,8 @@ export class AwsSecretsManager extends SecretsProvider {
|
|||
|
||||
private client: SecretsManager;
|
||||
|
||||
private settings: AwsSecretsManagerSettings;
|
||||
|
||||
constructor(
|
||||
private readonly logger = Container.get(Logger),
|
||||
private readonly outboundHttp = Container.get(OutboundHttp),
|
||||
|
|
@ -109,49 +120,69 @@ export class AwsSecretsManager extends SecretsProvider {
|
|||
}
|
||||
|
||||
async init(context: AwsSecretsManagerContext) {
|
||||
this.assertAuthType(context);
|
||||
|
||||
const { region, authMethod } = context.settings;
|
||||
const clientConfig: SecretsManagerClientConfig = { region };
|
||||
this.settings = { region, authMethod };
|
||||
|
||||
if (authMethod === 'iamUser') {
|
||||
const { accessKeyId, secretAccessKey } = context.settings;
|
||||
clientConfig.credentials = { accessKeyId, secretAccessKey };
|
||||
try {
|
||||
this.assertAuthType(context);
|
||||
|
||||
const clientConfig: SecretsManagerClientConfig = { region };
|
||||
|
||||
if (authMethod === 'iamUser') {
|
||||
const { accessKeyId, secretAccessKey } = context.settings;
|
||||
clientConfig.credentials = { accessKeyId, secretAccessKey };
|
||||
}
|
||||
|
||||
// Drive the AWS SDK's HTTP transport through n8n's outbound client,
|
||||
// so its calls reuse our agents (proxy + TLS) like every other outbound request.
|
||||
// SigV4 signing and the credential chain stay with the SDK.
|
||||
clientConfig.requestHandler = this.outboundHttp
|
||||
.transport({
|
||||
ssrf: 'disabled', // fixed AWS-resolved Secrets Manager host, not user-controlled
|
||||
})
|
||||
.getNodeAgent();
|
||||
|
||||
const { SecretsManager } = await import('@aws-sdk/client-secrets-manager');
|
||||
this.client = new SecretsManager(clientConfig);
|
||||
|
||||
this.logger.debug('AWS Secrets Manager provider initialized');
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to initialize AWS Secrets Manager provider', {
|
||||
operation: 'initialize',
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Drive the AWS SDK's HTTP transport through n8n's outbound client,
|
||||
// so its calls reuse our agents (proxy + TLS) like every other outbound request.
|
||||
// SigV4 signing and the credential chain stay with the SDK.
|
||||
clientConfig.requestHandler = this.outboundHttp
|
||||
.transport({
|
||||
ssrf: 'disabled', // fixed AWS-resolved Secrets Manager host, not user-controlled
|
||||
})
|
||||
.getNodeAgent();
|
||||
|
||||
const { SecretsManager } = await import('@aws-sdk/client-secrets-manager');
|
||||
this.client = new SecretsManager(clientConfig);
|
||||
|
||||
this.logger.debug('AWS Secrets Manager provider initialized');
|
||||
}
|
||||
|
||||
async test(): Promise<[boolean] | [boolean, string]> {
|
||||
try {
|
||||
await this.client.listSecrets({ MaxResults: 1 });
|
||||
await this.verifyConnection();
|
||||
return [true];
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(`${e}`);
|
||||
this.logOperationFailure('AWS Secrets Manager provider test failed', {
|
||||
operation: 'test',
|
||||
error,
|
||||
context: this.awsErrorContext(error),
|
||||
});
|
||||
return [false, error.message];
|
||||
}
|
||||
}
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
const [wasSuccessful, errorMsg] = await this.test();
|
||||
try {
|
||||
await this.verifyConnection();
|
||||
|
||||
if (!wasSuccessful) {
|
||||
throw new Error(errorMsg || 'Connection failed');
|
||||
this.logger.debug('AWS Secrets Manager provider connected');
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to connect AWS Secrets Manager provider', {
|
||||
operation: 'connect',
|
||||
error,
|
||||
context: this.awsErrorContext(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.debug('AWS Secrets Manager provider connected');
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
|
|
@ -159,15 +190,24 @@ export class AwsSecretsManager extends SecretsProvider {
|
|||
}
|
||||
|
||||
async update() {
|
||||
const secrets = await this.fetchAllSecrets();
|
||||
try {
|
||||
const secrets = await this.fetchAllSecrets();
|
||||
|
||||
const supportedSecrets = secrets;
|
||||
const supportedSecrets = secrets;
|
||||
|
||||
this.cachedSecrets = Object.fromEntries(
|
||||
supportedSecrets.map((s) => [s.secretName, s.secretValue]),
|
||||
);
|
||||
this.cachedSecrets = Object.fromEntries(
|
||||
supportedSecrets.map((s) => [s.secretName, s.secretValue]),
|
||||
);
|
||||
|
||||
this.logger.debug('AWS Secrets Manager provider secrets updated');
|
||||
this.logger.debug('AWS Secrets Manager provider secrets updated');
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to update AWS Secrets Manager provider secrets', {
|
||||
operation: 'update',
|
||||
error,
|
||||
context: this.awsErrorContext(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getSecret(name: string) {
|
||||
|
|
@ -239,4 +279,78 @@ export class AwsSecretsManager extends SecretsProvider {
|
|||
arr.slice(index * size, (index + 1) * size),
|
||||
);
|
||||
}
|
||||
|
||||
private async verifyConnection(): Promise<void> {
|
||||
await this.client.listSecrets({ MaxResults: 1 });
|
||||
}
|
||||
|
||||
private isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
private getAwsErrorCode(error: unknown): string | number | undefined {
|
||||
if (this.isRecord(error)) {
|
||||
if ('Code' in error && typeof error.Code === 'string') {
|
||||
return error.Code;
|
||||
}
|
||||
|
||||
if ('code' in error) {
|
||||
const { code } = error;
|
||||
if (typeof code === 'string' || typeof code === 'number') {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.name;
|
||||
}
|
||||
|
||||
if (this.isRecord(error) && typeof error.name === 'string') {
|
||||
return error.name;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private awsErrorContext(error: unknown): LogContext {
|
||||
const context: LogContext = {};
|
||||
|
||||
const errorCode = this.getAwsErrorCode(error);
|
||||
if (errorCode !== undefined) {
|
||||
context.errorCode = errorCode;
|
||||
}
|
||||
|
||||
if (this.isRecord(error) && '$metadata' in error) {
|
||||
const metadata = error.$metadata;
|
||||
if (this.isRecord(metadata) && typeof metadata.httpStatusCode === 'number') {
|
||||
context.statusCode = metadata.httpStatusCode;
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private logOperationFailure(
|
||||
message: string,
|
||||
params: SecretsProviderOperationFailureParams,
|
||||
): void {
|
||||
const context: LogContext = { ...params.context };
|
||||
if (this.settings?.region) {
|
||||
context.region = this.settings.region;
|
||||
}
|
||||
if (this.settings?.authMethod) {
|
||||
context.authMethod = this.settings.authMethod;
|
||||
}
|
||||
|
||||
logSecretsProviderOperationFailure({
|
||||
logger: this.logger,
|
||||
message,
|
||||
providerName: this.name,
|
||||
providerDisplayName: this.displayName,
|
||||
operation: params.operation,
|
||||
error: params.error,
|
||||
context,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { AuthenticationError } from '@azure/identity';
|
||||
import type { SecretClient } from '@azure/keyvault-secrets';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Container } from '@n8n/di';
|
||||
|
|
@ -6,8 +7,21 @@ import { type INodeProperties, UnexpectedError } from 'n8n-workflow';
|
|||
|
||||
import type { AzureKeyVaultContext } from './types';
|
||||
import { DOCS_HELP_NOTICE } from '../../constants';
|
||||
import {
|
||||
buildFailureSummaryLogContext,
|
||||
type HttpProviderErrorLogContext,
|
||||
type LogContext,
|
||||
logSecretsProviderOperationFailure,
|
||||
type SafeContextValue,
|
||||
type SecretsProviderOperationFailureParams,
|
||||
} from '../../errors/secrets-provider-errors';
|
||||
import { SecretsProvider } from '../../types';
|
||||
|
||||
type AzureHttpLikeError = Error & {
|
||||
statusCode?: number;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
export class AzureKeyVault extends SecretsProvider {
|
||||
name = 'azureKeyVault';
|
||||
|
||||
|
|
@ -76,16 +90,25 @@ export class AzureKeyVault extends SecretsProvider {
|
|||
}
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
const { vaultName, tenantId, clientId, clientSecret } = this.settings;
|
||||
try {
|
||||
const { vaultName, tenantId, clientId, clientSecret } = this.settings;
|
||||
|
||||
const { ClientSecretCredential } = await import('@azure/identity');
|
||||
const { SecretClient } = await import('@azure/keyvault-secrets');
|
||||
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);
|
||||
// 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);
|
||||
|
||||
this.logger.debug('Azure Key Vault provider connected');
|
||||
this.logger.debug('Azure Key Vault provider connected');
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to connect Azure Key Vault provider', {
|
||||
operation: 'connect',
|
||||
error,
|
||||
context: this.azureErrorContext(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async test(): Promise<[boolean] | [boolean, string]> {
|
||||
|
|
@ -95,6 +118,11 @@ export class AzureKeyVault extends SecretsProvider {
|
|||
await this.client.listPropertiesOfSecrets().next();
|
||||
return [true];
|
||||
} catch (error: unknown) {
|
||||
this.logOperationFailure('Azure Key Vault provider test failed', {
|
||||
operation: 'test',
|
||||
error,
|
||||
context: this.azureErrorContext(error),
|
||||
});
|
||||
return [false, error instanceof Error ? error.message : 'Unknown error'];
|
||||
}
|
||||
}
|
||||
|
|
@ -104,42 +132,91 @@ export class AzureKeyVault extends SecretsProvider {
|
|||
}
|
||||
|
||||
async update() {
|
||||
const secretNames: string[] = [];
|
||||
for await (const secret of this.client.listPropertiesOfSecrets()) {
|
||||
if (secret.enabled === false) continue;
|
||||
secretNames.push(secret.name);
|
||||
}
|
||||
try {
|
||||
const secretNames: string[] = [];
|
||||
for await (const secret of this.client.listPropertiesOfSecrets()) {
|
||||
if (secret.enabled === false) continue;
|
||||
secretNames.push(secret.name);
|
||||
}
|
||||
|
||||
const promises = await Promise.allSettled(
|
||||
secretNames.map(async (name) => {
|
||||
const { value } = await this.client.getSecret(name);
|
||||
return { name, value };
|
||||
}),
|
||||
);
|
||||
const promises = await Promise.allSettled(
|
||||
secretNames.map(async (name) => {
|
||||
const { value } = await this.client.getSecret(name);
|
||||
return { name, value };
|
||||
}),
|
||||
);
|
||||
|
||||
const updated: Record<string, string> = {};
|
||||
const readErrors: Error[] = [];
|
||||
for (const [index, promiseResult] of promises.entries()) {
|
||||
if (promiseResult.status === 'fulfilled') {
|
||||
const { name, value } = promiseResult.value;
|
||||
if (value !== undefined) updated[name] = value;
|
||||
} else {
|
||||
const error = ensureError(promiseResult.reason);
|
||||
readErrors.push(error);
|
||||
this.logger.warn(`Could not read Azure Key Vault secret "${secretNames[index]}"`, {
|
||||
error,
|
||||
const updated: Record<string, string> = {};
|
||||
const readErrors: Error[] = [];
|
||||
const failedSecrets: Array<{ name: string; errorCode: SafeContextValue }> = [];
|
||||
for (const [index, promiseResult] of promises.entries()) {
|
||||
if (promiseResult.status === 'fulfilled') {
|
||||
const { name, value } = promiseResult.value;
|
||||
if (value !== undefined) updated[name] = value;
|
||||
} else {
|
||||
const error = ensureError(promiseResult.reason);
|
||||
readErrors.push(error);
|
||||
const secretName = secretNames[index];
|
||||
const errorContext = this.azureErrorContext(error);
|
||||
this.logger.debug(`Could not read Azure Key Vault secret "${secretName}"`, {
|
||||
providerName: this.name,
|
||||
operation: 'update',
|
||||
vaultName: this.settings.vaultName,
|
||||
secretName,
|
||||
...errorContext,
|
||||
});
|
||||
failedSecrets.push({
|
||||
name: secretName,
|
||||
errorCode: errorContext.errorCode ?? 'unknown',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const failureSummary = buildFailureSummaryLogContext(failedSecrets);
|
||||
const isTotalFailure =
|
||||
secretNames.length > 0 && Object.keys(updated).length === 0 && readErrors.length > 0;
|
||||
|
||||
if (failureSummary && !isTotalFailure) {
|
||||
this.logOperationFailure('Skipped unreadable Azure Key Vault secrets during update', {
|
||||
operation: 'update',
|
||||
error:
|
||||
readErrors[0] ?? new Error('One or more Azure Key Vault secrets could not be read'),
|
||||
context: {
|
||||
...this.azureErrorContext(readErrors[0]),
|
||||
...failureSummary,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (secretNames.length > 0 && Object.keys(updated).length === 0 && readErrors.length > 0) {
|
||||
throw new UnexpectedError('Could not read any secrets from Azure Key Vault', {
|
||||
cause: readErrors[0],
|
||||
if (isTotalFailure) {
|
||||
const error = readErrors[0];
|
||||
this.logOperationFailure('Could not read any secrets from Azure Key Vault', {
|
||||
operation: 'update',
|
||||
error,
|
||||
context: {
|
||||
...this.azureErrorContext(error),
|
||||
...failureSummary,
|
||||
},
|
||||
});
|
||||
throw new UnexpectedError('Could not read any secrets from Azure Key Vault', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
this.cachedSecrets = updated;
|
||||
this.logger.debug('Azure Key Vault provider secrets updated');
|
||||
} catch (error) {
|
||||
if (error instanceof UnexpectedError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logOperationFailure('Failed to update Azure Key Vault provider secrets', {
|
||||
operation: 'update',
|
||||
error,
|
||||
context: this.azureErrorContext(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.cachedSecrets = updated;
|
||||
this.logger.debug('Azure Key Vault provider secrets updated');
|
||||
}
|
||||
|
||||
getSecret(name: string) {
|
||||
|
|
@ -153,4 +230,57 @@ export class AzureKeyVault extends SecretsProvider {
|
|||
getSecretNames() {
|
||||
return Object.keys(this.cachedSecrets);
|
||||
}
|
||||
|
||||
private isAzureHttpLikeError(error: unknown): error is AzureHttpLikeError {
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
const candidate = error as AzureHttpLikeError;
|
||||
return (
|
||||
error.name === 'RestError' ||
|
||||
typeof candidate.statusCode === 'number' ||
|
||||
typeof candidate.code === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
private azureErrorContext(error: unknown): HttpProviderErrorLogContext {
|
||||
if (error instanceof AuthenticationError) {
|
||||
return {
|
||||
statusCode: error.statusCode,
|
||||
errorCode: error.errorResponse?.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isAzureHttpLikeError(error)) {
|
||||
return {
|
||||
statusCode: error.statusCode,
|
||||
errorCode: error.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return { errorCode: error.name };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private logOperationFailure(
|
||||
message: string,
|
||||
params: SecretsProviderOperationFailureParams,
|
||||
): void {
|
||||
const context: LogContext = { ...params.context };
|
||||
if (this.settings?.vaultName) {
|
||||
context.vaultName = this.settings.vaultName;
|
||||
}
|
||||
|
||||
logSecretsProviderOperationFailure({
|
||||
logger: this.logger,
|
||||
message,
|
||||
providerName: this.name,
|
||||
providerDisplayName: this.displayName,
|
||||
operation: params.operation,
|
||||
error: params.error,
|
||||
context,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { protos, SecretManagerServiceClient as GcpClient } from '@google-cloud/secret-manager';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Container } from '@n8n/di';
|
||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||
import { jsonParse, UserError, type INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
|
|
@ -10,6 +9,13 @@ import type {
|
|||
RawGcpSecretAccountKey,
|
||||
} from './types';
|
||||
import { DOCS_HELP_NOTICE } from '../../constants';
|
||||
import {
|
||||
buildFailureSummaryLogContext,
|
||||
type LogContext,
|
||||
logSecretsProviderOperationFailure,
|
||||
type SafeContextValue,
|
||||
type SecretsProviderOperationFailureParams,
|
||||
} from '../../errors/secrets-provider-errors';
|
||||
import { SecretsProvider } from '../../types';
|
||||
|
||||
export class GcpSecretsManager extends SecretsProvider {
|
||||
|
|
@ -44,22 +50,40 @@ export class GcpSecretsManager extends SecretsProvider {
|
|||
}
|
||||
|
||||
async init(context: GcpSecretsManagerContext) {
|
||||
this.settings = this.parseSecretAccountKey(context.settings.serviceAccountKey);
|
||||
try {
|
||||
this.settings = this.parseSecretAccountKey(context.settings.serviceAccountKey);
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to initialize GCP Secrets Manager provider', {
|
||||
operation: 'initialize',
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
const { projectId, privateKey, clientEmail } = this.settings;
|
||||
try {
|
||||
const { projectId, privateKey, clientEmail } = this.settings;
|
||||
|
||||
const { SecretManagerServiceClient: GcpClient } = await import('@google-cloud/secret-manager');
|
||||
const { SecretManagerServiceClient: GcpClient } = await import(
|
||||
'@google-cloud/secret-manager'
|
||||
);
|
||||
|
||||
// TODO: gRPC bypasses @n8n/backend-network, so the configured proxy and SSRF/DNS rules are not enforced here.
|
||||
// Route through it once it supports a gRPC transport.
|
||||
this.client = new GcpClient({
|
||||
credentials: { client_email: clientEmail, private_key: privateKey },
|
||||
projectId,
|
||||
});
|
||||
// TODO: gRPC bypasses @n8n/backend-network, so the configured proxy and SSRF/DNS rules are not enforced here.
|
||||
// Route through it once it supports a gRPC transport.
|
||||
this.client = new GcpClient({
|
||||
credentials: { client_email: clientEmail, private_key: privateKey },
|
||||
projectId,
|
||||
});
|
||||
|
||||
this.logger.debug('GCP Secrets Manager provider connected');
|
||||
this.logger.debug('GCP Secrets Manager provider connected');
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to connect GCP Secrets Manager provider', {
|
||||
operation: 'connect',
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async test(): Promise<[boolean] | [boolean, string]> {
|
||||
|
|
@ -69,6 +93,10 @@ export class GcpSecretsManager extends SecretsProvider {
|
|||
await this.client.initialize();
|
||||
return [true];
|
||||
} catch (error: unknown) {
|
||||
this.logOperationFailure('GCP Secrets Manager provider test failed', {
|
||||
operation: 'test',
|
||||
error,
|
||||
});
|
||||
return [false, error instanceof Error ? error.message : 'Unknown error'];
|
||||
}
|
||||
}
|
||||
|
|
@ -78,73 +106,104 @@ export class GcpSecretsManager extends SecretsProvider {
|
|||
}
|
||||
|
||||
async update() {
|
||||
const { projectId } = this.settings;
|
||||
try {
|
||||
const { projectId } = this.settings;
|
||||
|
||||
const [rawSecretNames] = await this.client.listSecrets({
|
||||
parent: `projects/${projectId}`,
|
||||
});
|
||||
const [rawSecretNames] = await this.client.listSecrets({
|
||||
parent: `projects/${projectId}`,
|
||||
});
|
||||
|
||||
const secretNames = rawSecretNames.reduce<string[]>((acc, cur) => {
|
||||
if (!cur.name) return acc;
|
||||
const secretNames = rawSecretNames.reduce<string[]>((acc, cur) => {
|
||||
if (!cur.name) return acc;
|
||||
|
||||
const secretName = cur.name.split('/').pop();
|
||||
const secretName = cur.name.split('/').pop();
|
||||
|
||||
if (secretName) acc.push(secretName);
|
||||
if (secretName) acc.push(secretName);
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const promises = secretNames.map(async (name) => {
|
||||
let versions:
|
||||
| [
|
||||
protos.google.cloud.secretmanager.v1.IAccessSecretVersionResponse,
|
||||
protos.google.cloud.secretmanager.v1.IAccessSecretVersionRequest | undefined,
|
||||
{} | undefined,
|
||||
]
|
||||
| undefined;
|
||||
const skippedSecrets: Array<{ name: string; errorCode: SafeContextValue }> = [];
|
||||
let firstSkippedError: unknown;
|
||||
|
||||
try {
|
||||
versions = await this.client.accessSecretVersion({
|
||||
name: `projects/${projectId}/secrets/${name}/versions/latest`,
|
||||
});
|
||||
} catch (error) {
|
||||
// Only handle expected error codes that indicate the secret is not accessible
|
||||
// PERMISSION_DENIED (7), NOT_FOUND (5), UNAVAILABLE (14)
|
||||
const errorCode = error?.code;
|
||||
if (errorCode === 7 || errorCode === 5 || errorCode === 14) {
|
||||
this.logger.info(
|
||||
`Skipping GCP secret: ${name}, version: latest as the version is not accessible`,
|
||||
{
|
||||
error: ensureError(error),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Rethrow unexpected errors to avoid masking broader failures
|
||||
throw error;
|
||||
const promises = secretNames.map(async (name) => {
|
||||
let versions:
|
||||
| [
|
||||
protos.google.cloud.secretmanager.v1.IAccessSecretVersionResponse,
|
||||
protos.google.cloud.secretmanager.v1.IAccessSecretVersionRequest | undefined,
|
||||
{} | undefined,
|
||||
]
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
versions = await this.client.accessSecretVersion({
|
||||
name: `projects/${projectId}/secrets/${name}/versions/latest`,
|
||||
});
|
||||
} catch (error) {
|
||||
// Only handle expected error codes that indicate the secret is not accessible
|
||||
// PERMISSION_DENIED (7), NOT_FOUND (5), UNAVAILABLE (14)
|
||||
const errorCode = this.getGcpErrorCode(error);
|
||||
if (errorCode === 7 || errorCode === 5 || errorCode === 14) {
|
||||
if (firstSkippedError === undefined) {
|
||||
firstSkippedError = error;
|
||||
}
|
||||
this.logger.debug('Skipping inaccessible GCP secret version', {
|
||||
providerName: this.name,
|
||||
operation: 'update',
|
||||
projectId,
|
||||
secretName: name,
|
||||
errorCode,
|
||||
});
|
||||
skippedSecrets.push({
|
||||
name,
|
||||
errorCode: errorCode ?? 'unknown',
|
||||
});
|
||||
} else {
|
||||
// Rethrow unexpected errors to avoid masking broader failures
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}, {});
|
||||
|
||||
const failureSummary = buildFailureSummaryLogContext(skippedSecrets);
|
||||
if (failureSummary) {
|
||||
this.logOperationFailure('Skipped inaccessible GCP secret versions during update', {
|
||||
operation: 'update',
|
||||
error:
|
||||
firstSkippedError instanceof Error
|
||||
? firstSkippedError
|
||||
: new Error('One or more GCP secret versions were inaccessible'),
|
||||
context: failureSummary,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}, {});
|
||||
|
||||
this.logger.debug('GCP Secrets Manager provider secrets updated');
|
||||
this.logger.debug('GCP Secrets Manager provider secrets updated');
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to update GCP Secrets Manager provider secrets', {
|
||||
operation: 'update',
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getSecret(name: string) {
|
||||
|
|
@ -168,6 +227,9 @@ export class GcpSecretsManager extends SecretsProvider {
|
|||
const projectId = secretAccountKey.project_id?.trim();
|
||||
|
||||
if (!clientEmail || !privateKey) {
|
||||
this.logger.warn(
|
||||
'Service account key must contain "client_email" and "private_key" fields. Use the downloaded service account JSON key file from Google Cloud Console.',
|
||||
);
|
||||
throw new UserError(
|
||||
'Service account key must contain "client_email" and "private_key" fields. Use the downloaded service account JSON key file from Google Cloud Console.',
|
||||
);
|
||||
|
|
@ -179,4 +241,39 @@ export class GcpSecretsManager extends SecretsProvider {
|
|||
privateKey,
|
||||
};
|
||||
}
|
||||
|
||||
private getGcpErrorCode(error: unknown): number | string | undefined {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||||
const { code } = error;
|
||||
if (typeof code === 'number' || typeof code === 'string') {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private logOperationFailure(
|
||||
message: string,
|
||||
params: SecretsProviderOperationFailureParams,
|
||||
): void {
|
||||
const context: LogContext = { ...params.context };
|
||||
const errorCode = this.getGcpErrorCode(params.error);
|
||||
if (errorCode !== undefined) {
|
||||
context.errorCode = errorCode;
|
||||
}
|
||||
if (this.settings?.projectId) {
|
||||
context.projectId = this.settings.projectId;
|
||||
}
|
||||
|
||||
logSecretsProviderOperationFailure({
|
||||
logger: this.logger,
|
||||
message,
|
||||
providerName: this.name,
|
||||
providerDisplayName: this.displayName,
|
||||
operation: params.operation,
|
||||
error: params.error,
|
||||
context,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,18 @@ import { Container } from '@n8n/di';
|
|||
import { type INodeProperties, UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { DOCS_HELP_NOTICE } from '../constants';
|
||||
import {
|
||||
buildHttpProviderErrorContext,
|
||||
logSecretsProviderOperationFailure,
|
||||
type LogContext,
|
||||
type SecretsProviderOperationFailureParams,
|
||||
} from '../errors/secrets-provider-errors';
|
||||
import type { SecretsProviderSettings } from '../types';
|
||||
import { SecretsProvider } from '../types';
|
||||
|
||||
type InfisicalAuthMethod = 'universalAuth';
|
||||
export type InfisicalAuthMethod = 'universalAuth';
|
||||
|
||||
interface InfisicalSettings {
|
||||
export interface InfisicalSettings {
|
||||
siteURL: string;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
|
|
@ -26,25 +32,25 @@ interface InfisicalSettings {
|
|||
clientSecret: string;
|
||||
}
|
||||
|
||||
interface InfisicalUniversalAuthLoginResponse {
|
||||
export interface InfisicalUniversalAuthLoginResponse {
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
accessTokenMaxTTL: number;
|
||||
tokenType: string;
|
||||
}
|
||||
|
||||
interface InfisicalSecret {
|
||||
export interface InfisicalSecret {
|
||||
secretKey: string;
|
||||
secretValue: string;
|
||||
}
|
||||
|
||||
interface InfisicalImport {
|
||||
export interface InfisicalImport {
|
||||
secrets: InfisicalSecret[];
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
}
|
||||
|
||||
interface InfisicalListSecretsResponse {
|
||||
export interface InfisicalListSecretsResponse {
|
||||
secrets: InfisicalSecret[];
|
||||
imports: InfisicalImport[];
|
||||
}
|
||||
|
|
@ -178,19 +184,27 @@ export class InfisicalProvider extends SecretsProvider {
|
|||
protected async doConnect(): Promise<void> {
|
||||
this.refreshAbort = new AbortController();
|
||||
|
||||
if (this.settings.authMethod === 'universalAuth') {
|
||||
if (!this.settings.clientId || !this.settings.clientSecret) {
|
||||
throw new UnexpectedError('Client ID and Client Secret are required for Universal Auth');
|
||||
try {
|
||||
if (this.settings.authMethod === 'universalAuth') {
|
||||
if (!this.settings.clientId || !this.settings.clientSecret) {
|
||||
throw new UnexpectedError('Client ID and Client Secret are required for Universal Auth');
|
||||
}
|
||||
await this.loginUniversalAuth();
|
||||
}
|
||||
await this.loginUniversalAuth();
|
||||
}
|
||||
|
||||
const [testSuccess, testMessage] = await this.test();
|
||||
if (!testSuccess) {
|
||||
throw new Error(testMessage ?? 'Connection test failed');
|
||||
}
|
||||
await this.verifyWorkspaceAccess();
|
||||
|
||||
this.setupTokenRefresh();
|
||||
this.setupTokenRefresh();
|
||||
} catch (error) {
|
||||
const context =
|
||||
error instanceof UnexpectedError ? undefined : buildHttpProviderErrorContext(error);
|
||||
this.logOperationFailure('Failed to connect Infisical provider', {
|
||||
operation: 'connect',
|
||||
error,
|
||||
context,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
|
|
@ -206,32 +220,18 @@ export class InfisicalProvider extends SecretsProvider {
|
|||
|
||||
async test(): Promise<[boolean] | [boolean, string]> {
|
||||
try {
|
||||
const resp = await this.http.request({
|
||||
url: `/api/v1/workspace/${encodeURIComponent(this.settings.projectId)}`,
|
||||
method: 'GET',
|
||||
returnFullResponse: true,
|
||||
ignoreHttpStatusErrors: true, // Resolve non-2xx responses instead of throwing so the status checks below can return tailored messages.
|
||||
await this.verifyWorkspaceAccess();
|
||||
return [true];
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Infisical provider test failed', {
|
||||
operation: 'test',
|
||||
error,
|
||||
context: {
|
||||
...buildHttpProviderErrorContext(error),
|
||||
endpoint: 'workspace',
|
||||
},
|
||||
});
|
||||
|
||||
if (resp.statusCode >= 200 && resp.statusCode < 300) {
|
||||
return [true];
|
||||
}
|
||||
|
||||
if (resp.statusCode === 401) {
|
||||
return [false, 'Invalid credentials'];
|
||||
}
|
||||
if (resp.statusCode === 403) {
|
||||
return [
|
||||
false,
|
||||
'Permission denied. Verify the machine identity has access to this project.',
|
||||
];
|
||||
}
|
||||
if (resp.statusCode === 404) {
|
||||
return [false, 'Project not found. Check the Project ID and Site URL.'];
|
||||
}
|
||||
|
||||
return [false, `Unexpected response from Infisical (status ${resp.statusCode}).`];
|
||||
} catch (error) {
|
||||
if (isConnectionRefusedError(error)) {
|
||||
return [false, 'Connection refused. Check the Site URL.'];
|
||||
}
|
||||
|
|
@ -244,17 +244,31 @@ export class InfisicalProvider extends SecretsProvider {
|
|||
throw new UnexpectedError('Update attempted on Infisical before authentication');
|
||||
}
|
||||
|
||||
await this.ensureTokenFresh();
|
||||
|
||||
try {
|
||||
this.cacheSecrets(await this.fetchSecrets());
|
||||
} catch (error) {
|
||||
if (httpStatusFromError(error) === 401) {
|
||||
this.logger.debug('Infisical token rejected during update; re-authenticating and retrying');
|
||||
await this.loginUniversalAuth();
|
||||
await this.ensureTokenFresh();
|
||||
|
||||
try {
|
||||
this.cacheSecrets(await this.fetchSecrets());
|
||||
return;
|
||||
} catch (error) {
|
||||
if (httpStatusFromError(error) === 401) {
|
||||
this.logger.debug(
|
||||
'Infisical token rejected during update; re-authenticating and retrying',
|
||||
);
|
||||
await this.loginUniversalAuth();
|
||||
this.cacheSecrets(await this.fetchSecrets());
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to update Infisical provider secrets', {
|
||||
operation: 'update',
|
||||
error,
|
||||
context: {
|
||||
...buildHttpProviderErrorContext(error),
|
||||
endpoint: 'secrets',
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -337,8 +351,12 @@ export class InfisicalProvider extends SecretsProvider {
|
|||
await this.loginUniversalAuth();
|
||||
if (this.refreshAbort.signal.aborted) return;
|
||||
this.setupTokenRefresh();
|
||||
} catch {
|
||||
this.logger.error('Failed to refresh Infisical token. Attempting reconnect.');
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to refresh Infisical token. Attempting reconnect.', {
|
||||
operation: 'tokenRefresh',
|
||||
error,
|
||||
context: buildHttpProviderErrorContext(error),
|
||||
});
|
||||
void this.connect();
|
||||
}
|
||||
};
|
||||
|
|
@ -364,4 +382,55 @@ export class InfisicalProvider extends SecretsProvider {
|
|||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private async verifyWorkspaceAccess(): Promise<void> {
|
||||
const resp = await this.http.request({
|
||||
url: `/api/v1/workspace/${encodeURIComponent(this.settings.projectId)}`,
|
||||
method: 'GET',
|
||||
returnFullResponse: true,
|
||||
ignoreHttpStatusErrors: true, // Resolve non-2xx responses instead of throwing so the status checks below can return tailored messages.
|
||||
});
|
||||
|
||||
if (resp.statusCode >= 200 && resp.statusCode < 300) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (resp.statusCode === 401) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
if (resp.statusCode === 403) {
|
||||
throw new Error('Permission denied. Verify the machine identity has access to this project.');
|
||||
}
|
||||
if (resp.statusCode === 404) {
|
||||
throw new Error('Project not found. Check the Project ID and Site URL.');
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected response from Infisical (status ${resp.statusCode}).`);
|
||||
}
|
||||
|
||||
private logOperationFailure(
|
||||
message: string,
|
||||
params: SecretsProviderOperationFailureParams,
|
||||
): void {
|
||||
const context: LogContext = { ...params.context };
|
||||
if (this.settings) {
|
||||
const { siteURL, projectId, authMethod, environment, secretPath } = this.settings;
|
||||
Object.assign(context, { siteURL, projectId });
|
||||
if (params.operation === 'connect' || params.operation === 'tokenRefresh') {
|
||||
context.authMethod = authMethod;
|
||||
} else if (params.operation === 'update') {
|
||||
Object.assign(context, { environment, secretPath });
|
||||
}
|
||||
}
|
||||
|
||||
logSecretsProviderOperationFailure({
|
||||
logger: this.logger,
|
||||
message,
|
||||
providerName: this.name,
|
||||
providerDisplayName: this.displayName,
|
||||
operation: params.operation,
|
||||
error: params.error,
|
||||
context,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ import { Container } from '@n8n/di';
|
|||
import { UserError, type IDataObject, type INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { DOCS_HELP_NOTICE } from '../constants';
|
||||
import {
|
||||
buildHttpProviderErrorContext,
|
||||
logSecretsProviderOperationFailure,
|
||||
type LogContext,
|
||||
type SecretsProviderOperationFailureParams,
|
||||
} from '../errors/secrets-provider-errors';
|
||||
import { SecretsProvider, type SecretsProviderSettings } from '../types';
|
||||
|
||||
export type OnePasswordContext = SecretsProviderSettings<{
|
||||
|
|
@ -11,6 +17,11 @@ export type OnePasswordContext = SecretsProviderSettings<{
|
|||
accessToken: string;
|
||||
}>;
|
||||
|
||||
type OnePasswordSettings = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
export class OnePasswordProvider extends SecretsProvider {
|
||||
name = 'onePassword';
|
||||
|
||||
|
|
@ -45,7 +56,7 @@ export class OnePasswordProvider extends SecretsProvider {
|
|||
|
||||
private client: OPConnect;
|
||||
|
||||
private settings: { serverUrl: string; accessToken: string };
|
||||
private settings: OnePasswordSettings;
|
||||
|
||||
constructor(private readonly logger = Container.get(Logger)) {
|
||||
super();
|
||||
|
|
@ -53,51 +64,72 @@ export class OnePasswordProvider extends SecretsProvider {
|
|||
}
|
||||
|
||||
async init(context: OnePasswordContext) {
|
||||
const trimmedServerUrl = context.settings.serverUrl?.trim();
|
||||
const trimmedAccessToken = context.settings.accessToken?.trim();
|
||||
try {
|
||||
const trimmedServerUrl = context.settings.serverUrl?.trim();
|
||||
const trimmedAccessToken = context.settings.accessToken?.trim();
|
||||
|
||||
if (!trimmedServerUrl) {
|
||||
throw new UserError('Connect Server URL is required.');
|
||||
if (!trimmedServerUrl) {
|
||||
throw new UserError('Connect Server URL is required.');
|
||||
}
|
||||
|
||||
if (!trimmedAccessToken) {
|
||||
throw new UserError('Access Token is required.');
|
||||
}
|
||||
|
||||
this.settings = {
|
||||
serverUrl: trimmedServerUrl,
|
||||
accessToken: trimmedAccessToken,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to initialize 1Password provider', {
|
||||
operation: 'initialize',
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!trimmedAccessToken) {
|
||||
throw new UserError('Access Token is required.');
|
||||
}
|
||||
|
||||
this.settings = {
|
||||
serverUrl: trimmedServerUrl,
|
||||
accessToken: trimmedAccessToken,
|
||||
};
|
||||
}
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
const { OnePasswordConnect } = await import('@1password/connect');
|
||||
try {
|
||||
const { OnePasswordConnect } = await import('@1password/connect');
|
||||
|
||||
// TODO: the @1password/connect SDK exposes no transport/agent injection hook,
|
||||
// so requests bypass @n8n/backend-network and the configured proxy and SSRF/DNS rules are not enforced here.
|
||||
// Route through it once the SDK supports a custom client.
|
||||
this.client = OnePasswordConnect({
|
||||
serverURL: this.settings.serverUrl,
|
||||
token: this.settings.accessToken,
|
||||
keepAlive: true,
|
||||
});
|
||||
// TODO: the @1password/connect SDK exposes no transport/agent injection hook,
|
||||
// so requests bypass @n8n/backend-network and the configured proxy and SSRF/DNS rules are not enforced here.
|
||||
// Route through it once the SDK supports a custom client.
|
||||
this.client = OnePasswordConnect({
|
||||
serverURL: this.settings.serverUrl,
|
||||
token: this.settings.accessToken,
|
||||
keepAlive: true,
|
||||
});
|
||||
|
||||
const [wasSuccessful, errorMessage] = await this.test();
|
||||
await this.verifyConnection();
|
||||
|
||||
if (!wasSuccessful) {
|
||||
throw new Error(errorMessage || 'Connection failed');
|
||||
this.logger.debug('1Password provider connected');
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to connect 1Password provider', {
|
||||
operation: 'connect',
|
||||
error,
|
||||
context: buildHttpProviderErrorContext(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.debug('1Password provider connected');
|
||||
}
|
||||
|
||||
async test(): Promise<[boolean] | [boolean, string]> {
|
||||
if (!this.client) return [false, 'Client not initialized'];
|
||||
|
||||
try {
|
||||
await this.client.listVaults();
|
||||
await this.verifyConnection();
|
||||
return [true];
|
||||
} catch (error: unknown) {
|
||||
this.logOperationFailure('1Password provider test failed', {
|
||||
operation: 'test',
|
||||
error,
|
||||
context: {
|
||||
...buildHttpProviderErrorContext(error),
|
||||
endpoint: 'vaults',
|
||||
},
|
||||
});
|
||||
return [false, error instanceof Error ? error.message : 'Unknown error'];
|
||||
}
|
||||
}
|
||||
|
|
@ -107,38 +139,50 @@ export class OnePasswordProvider extends SecretsProvider {
|
|||
}
|
||||
|
||||
async update() {
|
||||
const vaults = await this.client.listVaults();
|
||||
try {
|
||||
const vaults = await this.client.listVaults();
|
||||
|
||||
const secrets: Record<string, IDataObject> = {};
|
||||
const secrets: Record<string, IDataObject> = {};
|
||||
|
||||
for (const vault of vaults) {
|
||||
if (!vault.id) continue;
|
||||
for (const vault of vaults) {
|
||||
if (!vault.id) continue;
|
||||
|
||||
const items = await this.client.listItems(vault.id);
|
||||
const items = await this.client.listItems(vault.id);
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.id || !item.title) continue;
|
||||
for (const item of items) {
|
||||
if (!item.id || !item.title) continue;
|
||||
|
||||
const fullItem = await this.client.getItemById(vault.id, item.id);
|
||||
const fullItem = await this.client.getItemById(vault.id, item.id);
|
||||
|
||||
if (!fullItem.fields?.length) continue;
|
||||
if (!fullItem.fields?.length) continue;
|
||||
|
||||
const fieldValues: IDataObject = {};
|
||||
for (const field of fullItem.fields) {
|
||||
if (field.label && field.value) {
|
||||
fieldValues[field.label] = field.value;
|
||||
const fieldValues: IDataObject = {};
|
||||
for (const field of fullItem.fields) {
|
||||
if (field.label && field.value) {
|
||||
fieldValues[field.label] = field.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(fieldValues).length === 0) continue;
|
||||
|
||||
secrets[item.title] = fieldValues;
|
||||
}
|
||||
|
||||
if (Object.keys(fieldValues).length === 0) continue;
|
||||
|
||||
secrets[item.title] = fieldValues;
|
||||
}
|
||||
|
||||
this.cachedSecrets = secrets;
|
||||
|
||||
this.logger.debug('1Password provider secrets updated');
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to update 1Password provider secrets', {
|
||||
operation: 'update',
|
||||
error,
|
||||
context: {
|
||||
...buildHttpProviderErrorContext(error),
|
||||
endpoint: 'secrets',
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.cachedSecrets = secrets;
|
||||
|
||||
this.logger.debug('1Password provider secrets updated');
|
||||
}
|
||||
|
||||
getSecret(name: string): IDataObject {
|
||||
|
|
@ -152,4 +196,28 @@ export class OnePasswordProvider extends SecretsProvider {
|
|||
getSecretNames() {
|
||||
return Object.keys(this.cachedSecrets);
|
||||
}
|
||||
|
||||
private async verifyConnection(): Promise<void> {
|
||||
await this.client.listVaults();
|
||||
}
|
||||
|
||||
private logOperationFailure(
|
||||
message: string,
|
||||
params: SecretsProviderOperationFailureParams,
|
||||
): void {
|
||||
const context: LogContext = { ...params.context };
|
||||
if (this.settings?.serverUrl) {
|
||||
context.serverUrl = this.settings.serverUrl;
|
||||
}
|
||||
|
||||
logSecretsProviderOperationFailure({
|
||||
logger: this.logger,
|
||||
message,
|
||||
providerName: this.name,
|
||||
providerDisplayName: this.displayName,
|
||||
operation: params.operation,
|
||||
error: params.error,
|
||||
context,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,20 @@ import {
|
|||
OutboundHttp,
|
||||
} from '@n8n/backend-network';
|
||||
import { Container } from '@n8n/di';
|
||||
import type {
|
||||
IDataObject,
|
||||
IHttpRequestMethods,
|
||||
IHttpRequestOptions,
|
||||
IN8nHttpFullResponse,
|
||||
INodeProperties,
|
||||
import {
|
||||
type IDataObject,
|
||||
type IHttpRequestMethods,
|
||||
type IHttpRequestOptions,
|
||||
type IN8nHttpFullResponse,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { DOCS_HELP_NOTICE } from '../constants';
|
||||
import {
|
||||
buildHttpProviderErrorContext,
|
||||
logSecretsProviderOperationFailure,
|
||||
type SecretsProviderOperationFailureParams,
|
||||
} from '../errors/secrets-provider-errors';
|
||||
import { ExternalSecretsConfig } from '../external-secrets.config';
|
||||
import type { SecretsProviderSettings } from '../types';
|
||||
import { SecretsProvider } from '../types';
|
||||
|
|
@ -291,33 +296,47 @@ export class VaultProvider extends SecretsProvider {
|
|||
}
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Authenticate based on method
|
||||
if (this.settings.authMethod === 'token') {
|
||||
this.#currentToken = this.settings.token;
|
||||
} else if (this.settings.authMethod === 'usernameAndPassword') {
|
||||
this.#currentToken = await this.authUsernameAndPassword(
|
||||
this.settings.username,
|
||||
this.settings.password,
|
||||
);
|
||||
if (!this.#currentToken) {
|
||||
throw new Error('Failed to authenticate with Username and Password');
|
||||
try {
|
||||
// Authenticate based on method
|
||||
if (this.settings.authMethod === 'token') {
|
||||
this.#currentToken = this.settings.token;
|
||||
} else if (this.settings.authMethod === 'usernameAndPassword') {
|
||||
this.#currentToken = await this.authUsernameAndPassword(
|
||||
this.settings.username,
|
||||
this.settings.password,
|
||||
);
|
||||
if (!this.#currentToken) {
|
||||
throw new Error('Failed to authenticate with Username and Password');
|
||||
}
|
||||
} else if (this.settings.authMethod === 'appRole') {
|
||||
this.#currentToken = await this.authAppRole(this.settings.roleId, this.settings.secretId);
|
||||
if (!this.#currentToken) {
|
||||
throw new Error('Failed to authenticate with AppRole');
|
||||
}
|
||||
}
|
||||
} else if (this.settings.authMethod === 'appRole') {
|
||||
this.#currentToken = await this.authAppRole(this.settings.roleId, this.settings.secretId);
|
||||
if (!this.#currentToken) {
|
||||
throw new Error('Failed to authenticate with AppRole');
|
||||
|
||||
// Test connection
|
||||
const [testSuccess, failureMessage] = await this.test();
|
||||
if (!testSuccess) {
|
||||
throw new Error(failureMessage ?? 'Connection test failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection
|
||||
const [testSuccess] = await this.test();
|
||||
if (!testSuccess) {
|
||||
throw new Error('Connection test failed');
|
||||
// Setup token refresh
|
||||
[this.#tokenInfo] = await this.getTokenInfo();
|
||||
this.setupTokenRefresh();
|
||||
} catch (error) {
|
||||
if (!this.isVaultAuthFailure(error)) {
|
||||
this.logOperationFailure('Failed to connect Vault provider', {
|
||||
operation: 'connect',
|
||||
error,
|
||||
context: {
|
||||
...buildHttpProviderErrorContext(error),
|
||||
authMethod: this.settings.authMethod,
|
||||
},
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Setup token refresh
|
||||
[this.#tokenInfo] = await this.getTokenInfo();
|
||||
this.setupTokenRefresh();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
|
|
@ -367,8 +386,15 @@ export class VaultProvider extends SecretsProvider {
|
|||
}
|
||||
|
||||
this.setupTokenRefresh();
|
||||
} catch {
|
||||
this.logger.error('Failed to renew Vault token. Attempting to reconnect.');
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to renew Vault token. Attempting to reconnect.', {
|
||||
operation: 'tokenRefresh',
|
||||
error,
|
||||
context: {
|
||||
...buildHttpProviderErrorContext(error),
|
||||
authMethod: this.settings.authMethod,
|
||||
},
|
||||
});
|
||||
void this.connect();
|
||||
}
|
||||
};
|
||||
|
|
@ -386,7 +412,15 @@ export class VaultProvider extends SecretsProvider {
|
|||
});
|
||||
|
||||
return body.auth.client_token;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Vault provider username/password authentication failed', {
|
||||
operation: 'connect',
|
||||
error,
|
||||
context: {
|
||||
...buildHttpProviderErrorContext(error),
|
||||
authMethod: 'usernameAndPassword',
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -401,7 +435,15 @@ export class VaultProvider extends SecretsProvider {
|
|||
});
|
||||
|
||||
return body.auth.client_token;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Vault provider AppRole authentication failed', {
|
||||
operation: 'connect',
|
||||
error,
|
||||
context: {
|
||||
...buildHttpProviderErrorContext(error),
|
||||
authMethod: 'appRole',
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -438,7 +480,18 @@ export class VaultProvider extends SecretsProvider {
|
|||
// non-standard `LIST` verb works; `preferGet` swaps it for `GET ?list=true`.
|
||||
const method = (shouldPreferGet ? 'GET' : 'LIST') as IHttpRequestMethods;
|
||||
listBody = await this.#http.request<VaultResponse<VaultSecretList>>({ url, method });
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const shouldPreferGet = Container.get(ExternalSecretsConfig).preferGet;
|
||||
const vaultApiPath = `${listPath}${shouldPreferGet ? '?list=true' : ''}`;
|
||||
const errorContext = buildHttpProviderErrorContext(error);
|
||||
this.logger.debug('Vault provider failed to list KV secrets', {
|
||||
providerName: this.name,
|
||||
operation: 'update',
|
||||
mountPath,
|
||||
kvVersion,
|
||||
vaultApiPath,
|
||||
...errorContext,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const data = Object.fromEntries(
|
||||
|
|
@ -463,7 +516,16 @@ export class VaultProvider extends SecretsProvider {
|
|||
key,
|
||||
kvVersion === '2' ? (secretBody.data.data as IDataObject) : secretBody.data,
|
||||
];
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const errorContext = buildHttpProviderErrorContext(error);
|
||||
this.logger.debug('Vault provider failed to read KV secret', {
|
||||
providerName: this.name,
|
||||
operation: 'update',
|
||||
mountPath,
|
||||
kvVersion,
|
||||
secretPath,
|
||||
...errorContext,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
|
|
@ -542,23 +604,33 @@ export class VaultProvider extends SecretsProvider {
|
|||
}
|
||||
|
||||
async update(): Promise<void> {
|
||||
const kvMounts = await this.discoverKvMounts();
|
||||
try {
|
||||
const kvMounts = await this.discoverKvMounts();
|
||||
|
||||
const secrets = Object.fromEntries(
|
||||
(
|
||||
await Promise.all(
|
||||
kvMounts.map(async ({ path, version }): Promise<[string, IDataObject] | null> => {
|
||||
const value = await this.getKVSecrets(path, version, '');
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return [path.substring(0, path.length - 1), value[1]];
|
||||
}),
|
||||
)
|
||||
).filter((entry): entry is [string, IDataObject] => entry !== null),
|
||||
);
|
||||
this.cachedSecrets = secrets;
|
||||
this.logger.debug('Vault provider secrets updated');
|
||||
const secrets = Object.fromEntries(
|
||||
(
|
||||
await Promise.all(
|
||||
kvMounts.map(async ({ path, version }): Promise<[string, IDataObject] | null> => {
|
||||
const value = await this.getKVSecrets(path, version, '');
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return [path.substring(0, path.length - 1), value[1]];
|
||||
}),
|
||||
)
|
||||
).filter((entry): entry is [string, IDataObject] => entry !== null),
|
||||
);
|
||||
this.cachedSecrets = secrets;
|
||||
|
||||
this.logger.debug('Vault provider secrets updated');
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Failed to update Vault provider secrets', {
|
||||
operation: 'update',
|
||||
error,
|
||||
context: buildHttpProviderErrorContext(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async test(): Promise<[boolean] | [boolean, string]> {
|
||||
|
|
@ -574,6 +646,15 @@ export class VaultProvider extends SecretsProvider {
|
|||
|
||||
return await this.testSecretAccess();
|
||||
} catch (error) {
|
||||
this.logOperationFailure('Vault provider test failed', {
|
||||
operation: 'test',
|
||||
error,
|
||||
context: {
|
||||
...buildHttpProviderErrorContext(error),
|
||||
vaultApiPath: 'auth/token/lookup-self',
|
||||
},
|
||||
});
|
||||
|
||||
if (isConnectionRefusedError(error)) {
|
||||
return [
|
||||
false,
|
||||
|
|
@ -630,4 +711,27 @@ export class VaultProvider extends SecretsProvider {
|
|||
ignoreHttpStatusErrors: true, // Resolve non-2xx responses instead of throwing so the status checks below can return tailored messages.
|
||||
});
|
||||
}
|
||||
|
||||
private isVaultAuthFailure(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === 'Failed to authenticate with Username and Password' ||
|
||||
error.message === 'Failed to authenticate with AppRole')
|
||||
);
|
||||
}
|
||||
|
||||
private logOperationFailure(
|
||||
message: string,
|
||||
params: SecretsProviderOperationFailureParams,
|
||||
): void {
|
||||
logSecretsProviderOperationFailure({
|
||||
logger: this.logger,
|
||||
message,
|
||||
providerName: this.name,
|
||||
providerDisplayName: this.displayName,
|
||||
operation: params.operation,
|
||||
error: params.error,
|
||||
context: params.context ?? {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user