'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