Skip to content

Commit

Permalink
refactor: key vault references (#88)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielfsousa authored Nov 7, 2019
1 parent 7077114 commit f6d1a65
Show file tree
Hide file tree
Showing 10 changed files with 301 additions and 332 deletions.
18 changes: 8 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,9 @@ yarn add dotenv-azure
#### Configuring App Configuration

1. [Create an app configuration store via Azure portal or CLI](https://docs.microsoft.com/en-us/azure/azure-app-configuration/quickstart-aspnet-core-app#create-an-app-configuration-store).
2. Set **AZURE_APP_CONFIG_URL** and **AZURE_APP_CONFIG_CONNECTION_STRING** as environment variables using bash or put them in a `.env` file:

> In production, if you are using [Azure Managed Identities](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview), you just have to set **AZURE_APP_CONFIG_URL**.
2. Set **AZURE_APP_CONFIG_CONNECTION_STRING** as environment variable using bash or put them in a `.env` file:

```bash
AZURE_APP_CONFIG_URL="https://your-app-config.azconfig.io"
AZURE_APP_CONFIG_CONNECTION_STRING="generated-app-config-conneciton-string"
```

Expand All @@ -60,14 +57,15 @@ AZURE_CLIENT_SECRET="random-password"
AZURE_TENANT_ID="tenant-ID"
```

If you have a configuration in Azure App Configuration with a value starting with `kv:`, `dotenv-azure` will try to load them from key vault.
If you have a configuration in App Configuration with the content type `application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8` then `dotenv-azure` will try to load it from Key Vault.

Let's assume you have created a secret in Key Vault, copied the secret url and created a new configuration in App Configuration with a value of the url of your secret:
```bash
DATABASE_URL=kv:https://your.vault.azure.net/secrets/DatabaseUrl/7091540ce97143deb08790a53fc2a75d
```
You can [add a Key Vault reference](https://docs.microsoft.com/en-us/azure/azure-app-configuration/use-key-vault-references-dotnet-core) to App Configuration in the Azure portal:

1. Sign in to the Azure portal. Select All resources, and then select the App Configuration store instance that you created in the quickstart
2. Select Configuration Explorer
3. Select + Create > Key vault reference

After calling `.config()` method, the value of your key vault scret will be set to process.env:
Now when you call the `.config()` method, the value of your key vault secret will be set to process.env:

```javascript
const { DotenvAzure } = require('dotenv-azure')
Expand Down
15 changes: 6 additions & 9 deletions config-safe.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@ const forceSync = require('sync-rpc')
const { populateProcessEnv } = require('./dist/lib/utils')
const configSync = forceSync(require.resolve('./config-rpc'))

try {
const { parsed } = configSync({
safe: true,
allowEmptyValues: true
})
populateProcessEnv(parsed)
} catch {
// noop
}
const { parsed } = configSync({
safe: true,
allowEmptyValues: true
})

populateProcessEnv(parsed)
8 changes: 2 additions & 6 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,5 @@ const forceSync = require('sync-rpc')
const { populateProcessEnv } = require('./dist/lib/utils')
const configSync = forceSync(require.resolve('./config-rpc'))

try {
const { parsed } = configSync()
populateProcessEnv(parsed)
} catch {
// noop
}
const { parsed } = configSync()
populateProcessEnv(parsed)
39 changes: 21 additions & 18 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dotenv-azure",
"version": "1.0.1-beta.1",
"version": "1.0.0-beta.6",
"description": "Load environment variables from Azure's services App Configuration, Key Vault or a .env file",
"keywords": [
"azure",
Expand Down Expand Up @@ -77,44 +77,47 @@
"@commitlint/config-conventional"
]
},
"resolutions": {
"typescript": "^3.7.2"
},
"devDependencies": {
"@commitlint/cli": "^8.1.0",
"@commitlint/config-conventional": "^8.1.0",
"@semantic-release/changelog": "^3.0.4",
"@commitlint/cli": "^8.2.0",
"@commitlint/config-conventional": "^8.2.0",
"@semantic-release/changelog": "^3.0.5",
"@semantic-release/git": "^7.1.0-beta.3",
"@types/dotenv": "^8.2.0",
"@types/jest": "^24.0.18",
"@types/node": "^12",
"@typescript-eslint/eslint-plugin": "^2.3.0",
"@typescript-eslint/parser": "^2.3.0",
"codecov": "^3.5.0",
"@types/jest": "^24.0.22",
"@types/node": "^12.12.6",
"@typescript-eslint/eslint-plugin": "^2.6.1",
"@typescript-eslint/parser": "^2.6.1",
"codecov": "^3.6.1",
"commitizen": "^4.0.3",
"cz-conventional-changelog": "^3.0.2",
"eslint": "^6.4.0",
"eslint-config-prettier": "^6.3.0",
"eslint": "^6.6.0",
"eslint-config-prettier": "^6.5.0",
"eslint-config-standard": "^14.1.0",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-node": "^10.0.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.1",
"husky": "^3.0.5",
"husky": "^3.0.9",
"jest": "^24.9.0",
"jest-config": "^24.9.0",
"lint-staged": "^9.2.5",
"lint-staged": "^9.4.2",
"prettier": "^1.18.2",
"rimraf": "^3.0.0",
"semantic-release": "^16.0.0-beta.24",
"ts-jest": "^24.1.0",
"ts-node": "^8.4.1",
"typedoc": "^0.15.0",
"typescript": "^3.6.3"
"typescript": "^3.7.2"
},
"dependencies": {
"@azure/app-configuration": "1.0.0-preview.3",
"@azure/identity": "1.0.0-preview.5",
"@azure/keyvault-secrets": "4.0.0-preview.8",
"@azure/app-configuration": "1.0.0-preview.7",
"@azure/identity": "1.0.0",
"@azure/keyvault-secrets": "4.0.0",
"bottleneck": "^2.19.5",
"dotenv": "^8.1.0",
"dotenv": "^8.2.0",
"sync-rpc": "^1.3.6"
}
}
147 changes: 79 additions & 68 deletions src/dotenv-azure.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,43 @@
import * as fs from 'fs'
import Bottleneck from 'bottleneck'
import dotenv, { DotenvParseOptions } from 'dotenv'
import dotenv, { DotenvParseOptions, DotenvParseOutput } from 'dotenv'
import { ManagedIdentityCredential, ClientSecretCredential } from '@azure/identity'
import { SecretsClient } from '@azure/keyvault-secrets'
import { AppConfigurationClient, ModelConfigurationSetting } from '@azure/app-configuration'
import { testIfValueIsVaultSecret, compact, difference, populateProcessEnv } from './utils'
import { SecretClient } from '@azure/keyvault-secrets'
import { AppConfigurationClient, ConfigurationSetting } from '@azure/app-configuration'
import { compact, difference, populateProcessEnv } from './utils'
import { MissingEnvVarsError, InvalidKeyVaultUrlError, MissingAppConfigCredentialsError } from './errors'
import {
DotenvAzureOptions,
DotenvAzureConfigOptions,
DotenvAzureConfigOutput,
DotenvAzureParseOutput,
VariablesObject,
AzureCredentials
AzureCredentials,
AppConfigurations,
KeyVaultReferenceInfo,
KeyVaultReferences
} from './types'

export default class DotenvAzure {
private readonly rateLimitMinTime: number
private readonly appConfigUrl?: string
private readonly keyVaultRateLimitMinTime: number
private readonly connectionString?: string
private readonly tenantId?: string
private readonly clientId?: string
private readonly clientSecret?: string
private readonly keyVaultClients: {
[vaultURL: string]: SecretsClient
[vaultURL: string]: SecretClient
}

/**
* Initializes a new instance of the DotenvAzure class.
*/
constructor({ appConfigUrl, rateLimit = 45 }: DotenvAzureOptions = {}) {
constructor({ rateLimit = 45, tenantId, clientId, clientSecret, connectionString }: DotenvAzureOptions = {}) {
this.keyVaultRateLimitMinTime = Math.ceil(1000 / rateLimit)
this.connectionString = connectionString
this.tenantId = tenantId
this.clientId = clientId
this.clientSecret = clientSecret
this.keyVaultClients = {}
this.appConfigUrl = appConfigUrl
this.rateLimitMinTime = Math.ceil(1000 / rateLimit)
}

/**
Expand Down Expand Up @@ -73,14 +82,14 @@ export default class DotenvAzure {
/**
* Loads your Azure App Configuration and Key Vault variables.
* It does not change {@link https://nodejs.org/api/process.html#process_process_env | `process.env`}.
* @param vars - an optional object with azure creadentials variables
* @param dotenvVars - dotenv parse() output containing azure credentials variables
* @returns an object with keys and values
*/
async loadFromAzure(vars?: VariablesObject): Promise<VariablesObject> {
const credentials = this.getAuthVariables(vars)
const appConfigClient = this.getAppConfigClient(credentials)
const appConfigVars = await this.getVariablesFromAppConfig(appConfigClient)
const keyVaultSecrets = await this.getSecretsFromKeyVault(credentials, appConfigVars)
async loadFromAzure(dotenvVars?: DotenvParseOutput): Promise<VariablesObject> {
const credentials = this.getAzureCredentials(dotenvVars)
const appConfigClient = new AppConfigurationClient(credentials.connectionString)
const { appConfigVars, keyVaultReferences: keyvaultReferences } = await this.getAppConfigurations(appConfigClient)
const keyVaultSecrets = await this.getSecretsFromKeyVault(credentials, keyvaultReferences)
return { ...appConfigVars, ...keyVaultSecrets }
}

Expand All @@ -94,91 +103,93 @@ export default class DotenvAzure {
}
}

protected async getVariablesFromAppConfig(client: AppConfigurationClient): Promise<VariablesObject> {
let vars: VariablesObject = {}
const request = await client.listConfigurationSettings()
const body = request._response.parsedBody

if (body.items) {
vars = body.items
.filter(item => item.key)
.reduce(
(acc, item: ModelConfigurationSetting) => ({
...acc,
[item.key || Symbol('key')]: item.value || ''
}),
{} as VariablesObject
)
protected async getAppConfigurations(client: AppConfigurationClient): Promise<AppConfigurations> {
const appConfigVars: VariablesObject = {}
const keyVaultReferences: KeyVaultReferences = {}

for await (const config of client.listConfigurationSettings()) {
if (this.isKeyVaultReference(config)) {
keyVaultReferences[config.key] = this.getKeyVaultReferenceInfo(config)
} else {
appConfigVars[config.key] = config.value
}
}

return vars
return { appConfigVars, keyVaultReferences }
}

protected async getSecretsFromKeyVault(
credentials: AzureCredentials,
vars: VariablesObject
vars: KeyVaultReferences
): Promise<VariablesObject> {
const secrets: VariablesObject = {}
// limit requests to avoid Azure AD rate limiting
const limiter = new Bottleneck({ minTime: this.rateLimitMinTime })

const getSecret = async (key: string, value: string): Promise<void> => {
const keyVaultUrl = testIfValueIsVaultSecret(value)
if (!keyVaultUrl) return
const limiter = new Bottleneck({ minTime: this.keyVaultRateLimitMinTime })

const [, , secretName, secretVersion] = keyVaultUrl.pathname.split('/')
if (!secretName || !secretVersion) {
throw new InvalidKeyVaultUrlError(key.replace('kv:', ''))
}

const keyVaultClient = this.getKeyVaultClient(credentials, keyVaultUrl.origin)
const response = await keyVaultClient.getSecret(secretName, { version: secretVersion })
secrets[key] = response.value || ''
const getSecret = async (key: string, info: KeyVaultReferenceInfo): Promise<void> => {
const keyVaultClient = this.getKeyVaultClient(credentials, info.vaultUrl.href)
const response = await keyVaultClient.getSecret(info.secretName, { version: info.secretVersion })
secrets[key] = response.value
}

await Promise.all(Object.entries(vars).map(([key, val]) => limiter.schedule(() => getSecret(key, val))))

const secretsPromises = Object.entries(vars).map(([key, val]) => limiter.schedule(() => getSecret(key, val)))
await Promise.all(secretsPromises)
return secrets
}

protected getAppConfigClient(credentials: AzureCredentials): AppConfigurationClient {
const { appConfigUrl = '', appConfigConnectionString } = credentials
if (appConfigConnectionString) {
return new AppConfigurationClient(appConfigConnectionString)
} else {
return new AppConfigurationClient(appConfigUrl, new ManagedIdentityCredential())
}
}

protected getKeyVaultClient(credentials: AzureCredentials, vaultURL: string): SecretsClient {
protected getKeyVaultClient(credentials: AzureCredentials, vaultURL: string): SecretClient {
const { tenantId, clientId, clientSecret } = credentials

if (!this.keyVaultClients[vaultURL]) {
if (tenantId && clientId && clientSecret) {
this.keyVaultClients[vaultURL] = new SecretsClient(
this.keyVaultClients[vaultURL] = new SecretClient(
vaultURL,
new ClientSecretCredential(tenantId, clientId, clientSecret)
)
} else {
this.keyVaultClients[vaultURL] = new SecretsClient(vaultURL, new ManagedIdentityCredential())
this.keyVaultClients[vaultURL] = new SecretClient(vaultURL, new ManagedIdentityCredential())
}
}

return this.keyVaultClients[vaultURL]
}

private getAuthVariables(dotenvVars: VariablesObject = {}): AzureCredentials {
protected getKeyVaultReferenceInfo({ key, value }: ConfigurationSetting): KeyVaultReferenceInfo {
try {
const obj = value && JSON.parse(value)
const keyVaultUrl = new URL(obj.uri)
const [, , secretName, secretVersion] = keyVaultUrl.pathname.split('/')
if (!secretName) {
throw new Error('KeyVault URL does not have a secret name')
}
return {
vaultUrl: new URL(keyVaultUrl.origin),
secretUrl: keyVaultUrl,
secretName,
secretVersion
}
} catch {
throw new InvalidKeyVaultUrlError(key)
}
}

protected isKeyVaultReference(config: ConfigurationSetting): boolean {
return config.contentType === 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8'
}

private getAzureCredentials(dotenvVars: DotenvParseOutput = {}): AzureCredentials {
const vars = { ...dotenvVars, ...process.env }
if (!vars.AZURE_APP_CONFIG_URL && !vars.AZURE_APP_CONFIG_CONNECTION_STRING && !this.appConfigUrl) {
const connectionString = this.connectionString || vars.AZURE_APP_CONFIG_CONNECTION_STRING

if (!connectionString) {
throw new MissingAppConfigCredentialsError()
}

return {
appConfigUrl: this.appConfigUrl || vars.AZURE_APP_CONFIG_URL,
appConfigConnectionString: vars.AZURE_APP_CONFIG_CONNECTION_STRING,
tenantId: vars.AZURE_TENANT_ID,
clientId: vars.AZURE_CLIENT_ID,
clientSecret: vars.AZURE_CLIENT_SECRET
connectionString,
tenantId: this.tenantId || vars.AZURE_TENANT_ID,
clientId: this.clientId || vars.AZURE_CLIENT_ID,
clientSecret: this.clientSecret || vars.AZURE_CLIENT_SECRET
}
}
}
Expand Down
Loading

0 comments on commit f6d1a65

Please sign in to comment.