feat(core): Introduce AWS secrets manager as external secrets store (#8982)

This commit is contained in:
Iván Ovejero
2024-03-28 10:15:58 +01:00
committed by GitHub
parent ae75cf414a
commit 2aab78b058
15 changed files with 380 additions and 11 deletions

View File

@@ -204,7 +204,7 @@ export class ExternalSecretsManager {
return Object.keys(this.providers);
}
getSecret(provider: string, name: string): IDataObject | undefined {
getSecret(provider: string, name: string) {
return this.getProvider(provider)?.getSecret(name);
}

View File

@@ -2,10 +2,12 @@ import type { SecretsProvider } from '@/Interfaces';
import { Service } from 'typedi';
import { InfisicalProvider } from './providers/infisical';
import { VaultProvider } from './providers/vault';
import { AwsSecretsManager } from './providers/aws-secrets/aws-secrets-manager';
@Service()
export class ExternalSecretsProviders {
providers: Record<string, { new (): SecretsProvider }> = {
awsSecretsManager: AwsSecretsManager,
infisical: InfisicalProvider,
vault: VaultProvider,
};

View File

@@ -0,0 +1,151 @@
import axios from 'axios';
import * as aws4 from 'aws4';
import type { AxiosRequestConfig } from 'axios';
import type { Request as Aws4Options } from 'aws4';
import type {
AwsSecretsManagerContext,
ConnectionTestResult,
Secret,
SecretsNamesPage,
SecretsPage,
AwsSecretsClientSettings,
} from './types';
export class AwsSecretsClient {
private settings: AwsSecretsClientSettings = {
region: '',
host: '',
url: '',
accessKeyId: '',
secretAccessKey: '',
};
constructor(settings: AwsSecretsManagerContext['settings']) {
const { region, accessKeyId, secretAccessKey } = settings;
this.settings = {
region,
host: `secretsmanager.${region}.amazonaws.com`,
url: `https://secretsmanager.${region}.amazonaws.com`,
accessKeyId,
secretAccessKey,
};
}
/**
* Check whether the client can connect to AWS Secrets Manager.
*/
async checkConnection(): ConnectionTestResult {
try {
await this.fetchSecretsNamesPage();
return [true];
} catch (e) {
const error = e instanceof Error ? e : new Error(`${e}`);
return [false, error.message];
}
}
/**
* Fetch all secrets from AWS Secrets Manager.
*/
async fetchAllSecrets() {
const secrets: Secret[] = [];
const allSecretsNames = await this.fetchAllSecretsNames();
const batches = this.batch(allSecretsNames);
for (const batch of batches) {
const page = await this.fetchSecretsPage(batch);
secrets.push(
...page.SecretValues.map((s) => ({ secretName: s.Name, secretValue: s.SecretString })),
);
}
return secrets;
}
private batch<T>(arr: T[], size = 20): T[][] {
return Array.from({ length: Math.ceil(arr.length / size) }, (_, index) =>
arr.slice(index * size, (index + 1) * size),
);
}
private toRequestOptions(
action: 'ListSecrets' | 'BatchGetSecretValue',
body: string,
): Aws4Options {
return {
method: 'POST',
service: 'secretsmanager',
region: this.settings.region,
host: this.settings.host,
headers: {
'X-Amz-Target': `secretsmanager.${action}`,
'Content-Type': 'application/x-amz-json-1.1',
},
body,
};
}
/**
* @doc https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_BatchGetSecretValue.html
*/
private async fetchSecretsPage(secretsNames: string[], nextToken?: string) {
const body = JSON.stringify(
nextToken
? { SecretIdList: secretsNames, NextToken: nextToken }
: { SecretIdList: secretsNames },
);
const options = this.toRequestOptions('BatchGetSecretValue', body);
const { headers } = aws4.sign(options, this.settings);
const config: AxiosRequestConfig = {
method: 'POST',
url: this.settings.url,
headers,
data: body,
};
const response = await axios.request<SecretsPage>(config);
return response.data;
}
private async fetchAllSecretsNames() {
const names: string[] = [];
let nextToken: string | undefined;
do {
const page = await this.fetchSecretsNamesPage(nextToken);
names.push(...page.SecretList.map((s) => s.Name));
nextToken = page.NextToken;
} while (nextToken);
return names;
}
/**
* @doc https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_ListSecrets.html
*/
private async fetchSecretsNamesPage(nextToken?: string) {
const body = JSON.stringify(nextToken ? { NextToken: nextToken } : {});
const options = this.toRequestOptions('ListSecrets', body);
const { headers } = aws4.sign(options, this.settings);
const config: AxiosRequestConfig = {
method: 'POST',
url: this.settings.url,
headers,
data: body,
};
const response = await axios.request<SecretsNamesPage>(config);
return response.data;
}
}

View File

@@ -0,0 +1,131 @@
import { AwsSecretsClient } from './aws-secrets-client';
import { UnknownAuthTypeError } from '@/errors/unknown-auth-type.error';
import { EXTERNAL_SECRETS_NAME_REGEX } from '@/ExternalSecrets/constants';
import type { SecretsProvider, SecretsProviderState } from '@/Interfaces';
import type { INodeProperties } from 'n8n-workflow';
import type { AwsSecretsManagerContext } from './types';
export class AwsSecretsManager implements SecretsProvider {
name = 'awsSecretsManager';
displayName = 'AWS Secrets Manager';
state: SecretsProviderState = 'initializing';
properties: INodeProperties[] = [
{
displayName:
'Need help filling out these fields? <a href="https://docs.n8n.io/external-secrets/#connect-n8n-to-your-secrets-store" target="_blank">Open docs</a>',
name: 'notice',
type: 'notice',
default: '',
noDataExpression: true,
},
{
displayName: 'Region',
name: 'region',
type: 'string',
default: '',
required: true,
placeholder: 'e.g. eu-west-3',
noDataExpression: true,
},
{
displayName: 'Authentication Method',
name: 'authMethod',
type: 'options',
options: [
{
name: 'IAM User',
value: 'iamUser',
description:
'Credentials for IAM user having <code>secretsmanager:ListSecrets</code> and <code>secretsmanager:BatchGetSecretValue</code> permissions. <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html" target="_blank">Learn more</a>',
},
],
default: 'iamUser',
required: true,
noDataExpression: true,
},
{
displayName: 'Access Key ID',
name: 'accessKeyId',
type: 'string',
default: '',
required: true,
placeholder: 'e.g. ACHXUQMBAQEVTE2RKMWP',
noDataExpression: true,
displayOptions: {
show: {
authMethod: ['iamUser'],
},
},
},
{
displayName: 'Secret Access Key',
name: 'secretAccessKey',
type: 'string',
default: '',
required: true,
placeholder: 'e.g. cbmjrH/xNAjPwlQR3i/1HRSDD+esQX/Lan3gcmBc',
typeOptions: { password: true },
noDataExpression: true,
displayOptions: {
show: {
authMethod: ['iamUser'],
},
},
},
];
private cachedSecrets: Record<string, string> = {};
private client: AwsSecretsClient;
async init(context: AwsSecretsManagerContext) {
this.assertAuthType(context);
this.client = new AwsSecretsClient(context.settings);
}
async test() {
return await this.client.checkConnection();
}
async connect() {
const [wasSuccessful] = await this.test();
this.state = wasSuccessful ? 'connected' : 'error';
}
async disconnect() {
return;
}
async update() {
const secrets = await this.client.fetchAllSecrets();
const supportedSecrets = secrets.filter((s) => EXTERNAL_SECRETS_NAME_REGEX.test(s.secretName));
this.cachedSecrets = Object.fromEntries(
supportedSecrets.map((s) => [s.secretName, s.secretValue]),
);
}
getSecret(name: string) {
return this.cachedSecrets[name];
}
hasSecret(name: string) {
return name in this.cachedSecrets;
}
getSecretNames() {
return Object.keys(this.cachedSecrets);
}
private assertAuthType(context: AwsSecretsManagerContext) {
if (context.settings.authMethod === 'iamUser') return;
throw new UnknownAuthTypeError(context.settings.authMethod);
}
}

View File

@@ -0,0 +1,50 @@
import type { SecretsProviderSettings } from '@/Interfaces';
export type SecretsNamesPage = {
NextToken?: string;
SecretList: SecretName[];
};
export type SecretsPage = {
NextToken?: string;
SecretValues: SecretValue[];
};
type SecretName = {
ARN: string;
CreatedDate: number;
LastAccessedDate: number;
LastChangedDate: number;
Name: string;
Tags: string[];
};
type SecretValue = {
ARN: string;
CreatedDate: number;
Name: string;
SecretString: string;
VersionId: string;
};
export type Secret = {
secretName: string;
secretValue: string;
};
export type ConnectionTestResult = Promise<[boolean] | [boolean, string]>;
export type AwsSecretsManagerContext = SecretsProviderSettings<{
region: string;
authMethod: 'iamUser';
accessKeyId: string;
secretAccessKey: string;
}>;
export type AwsSecretsClientSettings = {
region: string;
host: string;
url: string;
accessKeyId: string;
secretAccessKey: string;
};

View File

@@ -669,7 +669,7 @@ export abstract class SecretsProvider {
abstract disconnect(): Promise<void>;
abstract update(): Promise<void>;
abstract test(): Promise<[boolean] | [boolean, string]>;
abstract getSecret(name: string): IDataObject | undefined;
abstract getSecret(name: string): unknown;
abstract hasSecret(name: string): boolean;
abstract getSecretNames(): string[];
}

View File

@@ -1,4 +1,4 @@
import type { IDataObject, SecretsHelpersBase } from 'n8n-workflow';
import type { SecretsHelpersBase } from 'n8n-workflow';
import { Service } from 'typedi';
import { ExternalSecretsManager } from './ExternalSecrets/ExternalSecretsManager.ee';
@@ -19,7 +19,7 @@ export class SecretsHelper implements SecretsHelpersBase {
}
}
getSecret(provider: string, name: string): IDataObject | undefined {
getSecret(provider: string, name: string) {
return this.service.getSecret(provider, name);
}

View File

@@ -0,0 +1,7 @@
import { ApplicationError } from 'n8n-workflow';
export class UnknownAuthTypeError extends ApplicationError {
constructor(authType: string) {
super('Unknown auth type', { extra: { authType } });
}
}