-
Notifications
You must be signed in to change notification settings - Fork 12.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): Introduce AWS secrets manager as external secrets store (#…
- Loading branch information
Showing
15 changed files
with
380 additions
and
11 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
151 changes: 151 additions & 0 deletions
151
packages/cli/src/ExternalSecrets/providers/aws-secrets/aws-secrets-client.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |
131 changes: 131 additions & 0 deletions
131
packages/cli/src/ExternalSecrets/providers/aws-secrets/aws-secrets-manager.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
} |
50 changes: 50 additions & 0 deletions
50
packages/cli/src/ExternalSecrets/providers/aws-secrets/types.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.