'use strict'
const request = require('request-micro')
const backoff = require('exponential-backoff')
const urlJoin = require('path').posix.join
const FormData = require('form-data')
const packageData = require('./package.json')
const { getAuthenticationHeaders } = require('./lib/sts')
const { log, noop } = require('./lib/log')
const globalHeaders = {
'X-Cerberus-Client': `CerberusNodeClient/${packageData.version}`
}
const cerberusVersion = 'v1'
const DEFAULT_RETRY_ATTEMPT_NUMBER = 3
/**
* Options for creating a {@link CerberusClient}
* @interface CerberusClientOptions
* @typedef CerberusClientOptions
* @type {object}
* @property {string} hostUrl required base url for the Cerberus API.
* @property {string} [region] region to sign sts auth request for, defaults to us-west-2
* @property {string} [token] Override the cerberus token. Useful for testing
* @property {boolean} [debug] If set to true additional logging occurs.
*/
/**
* @interface ListKeyResult
* @typedef ListKeyResult
* @type {object}
* @property {array<string>} keys
*/
/**
* @interface ListFileResult
* @typedef ListFileResult
* @type {object}
* @property {boolean} has_next If the result requires pagination
* @property {string} next_offset The offset to use for the next page
* @property {number} limit The limit that was used for the results
* @property {number} offset The offset of the results
* @property {number} file_count_in_result Number of files in result
* @property {number} total_file_count Number of total files under path
* @property {array<SecureFileSummary>} secure_file_summaries
*/
/**
* @interface SecureFileSummary
* @typedef SecureFileSummary
* @type {object}
* @property {string} sdbox_id The SDB id
* @property {string} path The path for the file
* @property {number} size_in_bytes The size in bytes of the file
* @property {string} name The name of the file
* @property {string} created_by Who originally uploaded the file
* @property {string} created_ts ISO 8061 String of when the file was originally uploaded
* @property {string} last_updated_by Who last updated the file
* @property {string} last_updated_ts ISO 8061 String of when the file was last updated
*/
/**
* Cerberus client with CRUD operations for secure data and files.
*
* @example
* var CerberusClient = require('cerberus-node-client')
*
* var client = new CerberusClient({
* // string, The cerberus URL to use.
* hostUrl: YOUR_CERBERUS_HOST,
*
* // string, AWS region, required if authenticating to AWS China, otherwise defaults to us-west-2
* region: AWS_REGION,
*
* // boolean, defaults to false. When true will console.log many operations
* debug: true,
*
* // This will be used as the cerberus X-Vault-Token if supplied
* // OVERRIDDEN by process.env.CERBERUS_TOKEN
* // If present, normal authentication with cerberus will be skipped
* // You should normally only be using this in testing environments
* // When developing locally, it is easier to use process.env.CERBERUS_TOKEN
* token: 'Some_Auth_Token'
* })
*
* client.getSecureData('path/to/my/secret').then(secureConfig => {
* //do something with config
* })
*/
class CerberusClient {
/**
* @param {CerberusClientOptions} options The options for the Cerberus client.
*/
constructor (options) {
if (!options || typeof options !== 'object') {
throw new Error('options parameter is required')
}
this._log = options.debug ? log : noop
if (options.token) {
this._log('constructor options token found', options.token)
this._token = options.token
} else {
// Override context with env variables
const envToken = getEnvironmentVariable(process.env.CERBERUS_TOKEN)
if (envToken) {
this._log('environment variable token found', envToken)
this._token = envToken
}
}
// Validate configuration
if (typeof options.hostUrl !== 'string') {
throw new Error('options.hostUrl must be a URL string')
}
this._hostUrl = options.hostUrl
this._region = options.region ? options.region : 'us-west-2'
}
/**
* Fetches secure data.
*
* @param {string} path The path for the secure data
* @return {Promise<object>} A promise that when resolved supplies the secure data
*/
getSecureData (path) {
return this._doSecretAction('GET', path, undefined)
}
/**
* Writes secure data.
*
* @param {string} path The path for the secure data
* @param {object} data The secure data
* @return {Promise<undefined>} A promise that will be resolved when the write is finished
*/
writeSecureData (path, data) {
return this._doSecretAction('POST', path, data)
}
/**
* Deletes secure data.
*
* @param {string} path The path for the secure data
* @return {Promise<object>} A promise that will be resolved when the delete is finished
*/
deleteSecureData (path) {
return this._doSecretAction('DELETE', path, undefined)
}
/**
* lists the keys under a secure data path.
*
* If no keys are present {ListKeyResult} will have an empty array.
*
* @param {string} path The path or partial path
* @return {Promise<ListKeyResult>} A promise that will be resolved when the list is finished supplying the results
*/
async listPathsForSecureData (path) {
let res
try {
res = await this._doSecretAction('LIST', path, undefined)
} catch (e) {
// If no keys under a partial path can be found the API returns a 404, lets convert that to set of empty keys
if (e.message.includes('status code: 404')) {
res = { keys: [] }
} else {
const msg = 'There was an issue trying to list paths for secure data\nmsg: ' + e.message
this._log(msg)
throw new Error(msg)
}
}
return res
}
/**
* lists the files under a path.
*
* @param {string} path The path or partial path
* @return {Promise<ListFileResult>} A promise that will be resolved when the list is finished supplying the {ListFileResult}
*/
listFile (path) {
return this._doFileAction('LIST', path, undefined)
}
/**
* Reads the contents of an uploaded file
*
* @param {string} path The path the the uploaded file
* @return {Promise<Buffer|string>} A promise that will be resolved when the file contents have been fetched
*/
readFile (path) {
return this._doFileAction('GET', path, undefined)
}
/**
* Uploads a file to a given path
*
* @param {string} path The path
* @param {string|Buffer} data The file buffer or string
* @return {Promise<object>} A promise that will be resolved when the file contents have been uploaded
*/
writeFile (path, data) {
return this._doFileAction('POST', path, data)
}
/**
* deletes an uploaded file
*
* @param {string} path The path the the uploaded file
* @return {Promise<object>} A promise that will be resolved when the file contents have been deleted
*/
deleteFile (path) {
return this._doFileAction('DELETE', path, undefined)
}
/**
* Performs an API action against the secret endpoint in Cerberus
*
* @param type The type of secret action
* @param {string} path The secure data path
* @param body The post for writes
* @return {Promise<*>} This method returns a promised that when resolved will supply the secure data.
* @private
*/
async _doSecretAction (type, path, body) {
this._log(`Starting ${type} request for ${path}`)
const token = await this._getToken()
let url = new URL(urlJoin(cerberusVersion, 'secret', path), this._hostUrl).href
if (type === 'LIST') {
url += '?list=true'
}
const response = await this._executeCerberusRequest({
headers: Object.assign({}, globalHeaders, { 'X-Cerberus-Token': token }),
method: type === 'LIST' ? 'GET' : type,
url,
body
})
return response ? response.data : undefined
}
/**
* Executes a request against the Cerberus API dealing with any error cases.
*
* @param requestConfig The request configuration to be executed
* @return {Promise<*>} The response body from Cerberus
* @private
*/
async _executeCerberusRequest (requestConfig) {
let response
try {
response = await this._executeRequest(Object.assign({}, { json: true }, requestConfig))
if (!response) {
throw new Error('No response was returned from Cerberus')
}
} catch (error) {
const msg = 'There was an error executing a call to Cerberus.\nmsg: \'' + error.message + '\''
this._log(msg)
throw new Error(msg)
}
if (!(response.statusCode >= 200 && response.statusCode < 300)) {
if (response.headers['content-type'] && response.headers['content-type'].startsWith('application/json')) {
const msg = `Cerberus returned an error, when executing a call.\nstatus code: ${response.statusCode}\nmsg: ${JSON.stringify(response.data)}`
this._log(msg)
throw new Error(msg)
} else {
const msg = `Cerberus returned a non-success response that wasn't JSON, this is likely due to being blocked by the WAF.\nstatus code: ${response.statusCode}\nmsg: ${response.data ? response.data : ''}`
this._log(msg)
throw new Error(msg)
}
}
return response.data
}
// noinspection JSMethodCanBeStatic
/**
* Uses the micro request library to execute the request
* @param requestConfig
* @return {promise<*>}
* @private
*/
async _executeRequest (requestConfig) {
let resp
try {
return await backoff.backOff(async () => {
resp = await request(requestConfig)
if (!resp) {
return resp
} else if (resp.statusCode < 500) {
return resp
} else {
throw new Error('Cerberus returned a Server Error, executing retry')
}
}, { numOfAttempts: DEFAULT_RETRY_ATTEMPT_NUMBER })
} catch (e) {
return resp
}
}
/**
* Uses setTimeout to make a sleep function
*
* @param {int} type - the number of milliseconds to sleep for
* @returns {promise<*>}
* @private
*/
_sleep (ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Upload, delete, read, and list files on Cerberus.
*
* @param {string} type - The HTTP method (with the exception of 'LIST') to use as outlined in https://github.com/Nike-Inc/cerberus-management-service/blob/master/API.md
* @param {string} filePath - The path of the file
* @param {string|Buffer} fileBuffer - Buffer of the file to upload
* @returns {Promise<object>} Buffer if read file and Cerberus response otherwise
* @private
*/
async _doFileAction (type, filePath, fileBuffer) {
this._log(`Starting ${type} file request for ${filePath}`)
const token = await this._getToken()
let form
if (fileBuffer) {
form = new FormData({})
form.append('file-content', fileBuffer, { filename: filePath.match(/([^/]*)\/*$/)[1] })
}
let securePath = 'secure-file'
if (type === 'LIST') {
securePath += 's'
}
const pathName = urlJoin(cerberusVersion, securePath, filePath)
const url = new URL(pathName, this._hostUrl).href
const data = await this._executeCerberusRequest({
method: type === 'LIST' ? 'GET' : type,
url,
headers: Object.assign({}, globalHeaders, { 'X-Cerberus-Token': token }, type === 'POST' ? form.getHeaders() : undefined),
body: form,
json: type === 'LIST' || type === 'DELETE'
})
return data
}
/**
* Fetches a token either from a local env var or attempts to authenticate with Cerberus via the STS authentication endpoint.
*
* @return {Promise<string>} when the promise is resolved the Cerberus auth token string is supplied.
* @private
*/
async _getToken () {
// tokenExpiresAt in secs, Date.now in ms
if (this._tokenExpiresAt && (this._tokenExpiresAt <= (Date.now() / 1000))) {
this._tokenExpiresAt = null
this._token = null
}
// Already has token
if (this._token) {
this._log('returning stored token')
return this._token
}
let authResponse
try {
const authHeaders = await getAuthenticationHeaders(this._region)
authResponse = await this._executeCerberusRequest({
method: 'POST',
url: new URL('v2/auth/sts-identity', this._hostUrl).href,
headers: Object.assign({}, globalHeaders, authHeaders)
})
} catch (error) {
const msg = 'There was an issue trying to authenticate with Cerberus using the STS auth endpoint\nmsg: \'' + error.message + '\''
this._log(msg)
throw new Error(msg)
}
if (authResponse.metadata.aws_iam_principal_arn != null) {
this._log('Successfully authenticated with Cerberus as ' + authResponse.metadata.aws_iam_principal_arn)
} else if (authResponse.metadata.username != null) {
this._log('Successfully authenticated with Cerberus as ' + authResponse.metadata.username)
}
// Expire 60 seconds before lease is up, to account for latency
this._tokenExpiresAt = (Date.now() / 1000) + authResponse.lease_duration - 60 // token TTL in secs, Date.now in ms
this._token = authResponse.client_token
return this._token
}
}
/**
* Gets the set value or undefined
*
* @param value The value under question
* @return {String|undefined} Returns the string of the value or undefined
* @private
*/
const getEnvironmentVariable = (value) => {
return value && value !== 'undefined' && value !== undefined && value !== null ? value : undefined
}
module.exports = CerberusClient