diff --git a/bin/clever.js b/bin/clever.js index f48c6c353..b62dcc794 100755 --- a/bin/clever.js +++ b/bin/clever.js @@ -38,6 +38,7 @@ import * as domain from '../src/commands/domain.js'; import * as drain from '../src/commands/drain.js'; import * as env from '../src/commands/env.js'; import * as features from '../src/commands/features.js'; +import * as kms from '../src/commands/kms.js'; import * as kv from '../src/commands/kv.js'; import * as link from '../src/commands/link.js'; import * as login from '../src/commands/login.js'; @@ -92,6 +93,8 @@ async function run () { // ARGUMENTS const args = { + kmsKeyValue: cliparse.argument('key-value', { description: 'A key/value to store in a Clever KMS secret (e.g. secretKey=secretValue), can be used multiple times' }), + kmsSecret: cliparse.argument('secret', { description: 'The secret to get from Clever KMS' }), kvRawCommand: cliparse.argument('command', { description: 'The raw command to send to the Materia KV or Redis® add-on' }), kvIdOrName: cliparse.argument('kv-id', { description: 'Add-on/Real ID (or name, if unambiguous) of a Materia KV or Redis® add-on', @@ -774,6 +777,25 @@ async function run () { commands: [enableFeatureCommand, disableFeatureCommand, listFeaturesCommand, infoFeaturesCommand], }, features.list); + // KMS COMMANDS + const kmsGetCommand = cliparse.command('get', { + description: 'Get the value of a secret', + args: [args.kmsSecret], + }, kms.get); + const kmsPatchCommand = cliparse.command('patch', { + description: 'Patch an existing secret', + args: [args.kmsSecret, args.kmsKeyValue], + }, kms.patch); + const kmsPutCommand = cliparse.command('put', { + description: 'Set the value of a secret', + args: [args.kmsSecret, args.kmsKeyValue], + }, kms.put); + const kmsCommands = cliparse.command('kms', { + description: 'Manage secrets', + options: [opts.humanJsonOutputFormat], + commands: [kmsGetCommand, kmsPatchCommand, kmsPutCommand], + }, kms.get); + // KV COMMAND const kvRawCommand = cliparse.command('kv', { description: 'Send a raw command to a Materia KV or Redis® add-on', @@ -1109,18 +1131,22 @@ async function run () { // Add experimental features only if they are enabled through the configuration file const featuresFromConf = await getFeatures(); - if (featuresFromConf.kv) { - commands.push(colorizeExperimentalCommand(kvRawCommand, 'kv')); + if (featuresFromConf.kms) { + commands.push(colorizeExperimentalCommand(kmsCommands, 'kms')); } - if (featuresFromConf.tokens) { - commands.push(colorizeExperimentalCommand(tokensCommands, 'tokens')); + if (featuresFromConf.kv) { + commands.push(colorizeExperimentalCommand(kvRawCommand, 'kv')); } if (featuresFromConf.ng) { commands.push(colorizeExperimentalCommand(networkGroupsCommand, 'ng')); } + if (featuresFromConf.tokens) { + commands.push(colorizeExperimentalCommand(tokensCommands, 'tokens')); + } + // CLI PARSER const cliParser = cliparse.cli({ name: 'clever', diff --git a/src/commands/kms.js b/src/commands/kms.js new file mode 100644 index 000000000..a62ebb626 --- /dev/null +++ b/src/commands/kms.js @@ -0,0 +1,73 @@ +import { Logger } from '../logger.js'; +import { getSecret, patchSecret, putSecret } from '../models/kms.js'; + +/** + * Get secret from Clever KMS + * @param {*} params + * @param {string} params.args[0] - secret name + * @param {object} params.options.format - output format + * @returns {Promise} + */ +export async function get (params) { + const [fieldKey] = params.args; + const { format } = params.options; + + const secret = await getSecret(fieldKey); + + switch (format) { + case 'json': + Logger.printJson(secret.data.data); + break; + case 'human': + default: + console.table(secret.data.data); + } +} + +/** + * Patch secret in Clever KMS + * @param {object} params + * @param {string} params.args[0] - secret name + * @param {Array} params.args[...n] - key=value pairs + * @param {object} params.options.format - output format + * @returns {Promise} + */ +export async function patch (params) { + const [fieldKey, ...fieldValues] = params.args; + const { format } = params.options; + + const response = await patchSecret(fieldKey, fieldValues); + + switch (format) { + case 'json': + Logger.printJson(response.data); + break; + case 'human': + default: + console.table(response.data); + } +} + +/** + * Put secret in Clever KMS + * @param {object} params + * @param {string} params.args[0] - secret name + * @param {Array} params.args[...n] - key=value pairs + * @param {object} params.options.format - output format + * @returns {Promise} + */ +export async function put (params) { + const [fieldKey, ...fieldValues] = params.args; + const { format } = params.options; + + const response = await putSecret(fieldKey, fieldValues); + + switch (format) { + case 'json': + Logger.printJson(response.data); + break; + case 'human': + default: + console.table(response.data); + } +} diff --git a/src/experimental-features.js b/src/experimental-features.js index cbf12da4b..534ab0661 100644 --- a/src/experimental-features.js +++ b/src/experimental-features.js @@ -2,6 +2,20 @@ import dedent from 'dedent'; import { conf } from './models/configuration.js'; export const EXPERIMENTAL_FEATURES = { + kms: { + status: 'alpha', + description: 'Manage your secrets in Clever KMS', + instructions: ` +GET and PUT secret in Clever KMS directly from Clever Tools, without other dependencies + + clever kms put mySecret mySecretKey=mySecretValue + clever kms put myOtherSecret mySecretKey=mySecretValue myOtherSecretKey="$MY_ENV_VAR_SECRET_VALUE" + clever kms get mySecret + clever kms get myOtherSecret -F json + +Learn more about Materia KV: https://www.clever-cloud.com/developers/doc/addons/kms + `, + }, kv: { status: 'alpha', description: 'Send commands to databases such as Materia KV or Redis® directly from Clever Tools, without other dependencies', diff --git a/src/models/configuration.js b/src/models/configuration.js index 36398bc25..2a9603e85 100644 --- a/src/models/configuration.js +++ b/src/models/configuration.js @@ -124,6 +124,7 @@ export async function setFeature (feature, value) { export const conf = env.getOrElseAll({ API_HOST: 'https://api.clever-cloud.com', AUTH_BRIDGE_HOST: 'https://api-bridge.clever-cloud.com', + KMS_HOST: process.env.VAULT_ADDR, SSH_GATEWAY: 'ssh@sshgateway-clevercloud-customers.services.clever-cloud.com', // the disclosure of these tokens is not considered as a vulnerability. Do not report this to our security service. diff --git a/src/models/kms-api.js b/src/models/kms-api.js new file mode 100644 index 000000000..62837c9a0 --- /dev/null +++ b/src/models/kms-api.js @@ -0,0 +1,46 @@ +/** + * GET /v1/secret/data/{secret} + * @param {Object} params + * @param {String} params.secret + */ +export function getSecret (params) { + return Promise.resolve({ + method: 'get', + url: `/v1/secret/data/${params.secret}`, + headers: { Accept: 'application/json' }, + // no query params + // no body + }); +} + +/** + * PATCH /v1/secret/data/{secret} + * @param {Object} params + * @param {String} params.secret + * @param {Object} body + */ +export function patchSecret (params, body) { + return Promise.resolve({ + method: 'patch', + url: `/v1/secret/data/${params.secret}`, + headers: { 'Content-Type': 'application/json' }, + body, + // no query params + }); +} + +/** + * PUT /v1/secret/data/{secret} + * @param {Object} params + * @param {String} params.secret + * @param {Object} body + */ +export function putSecret (params, body) { + return Promise.resolve({ + method: 'post', + url: `/v1/secret/data/${params.secret}`, + headers: { 'Content-Type': 'application/json' }, + body, + // no query params + }); +} diff --git a/src/models/kms.js b/src/models/kms.js new file mode 100644 index 000000000..222c7adbe --- /dev/null +++ b/src/models/kms.js @@ -0,0 +1,87 @@ +import fetch from 'node-fetch'; +import colors from 'colors/safe.js'; +import * as KMS from './kms-api.js'; +import { conf } from './configuration.js'; + +/** + * Get secret from Clever KMS + * @param {string} secret + * @returns {Promise} secret data + */ +export async function getSecret (secret) { + return KMS.getSecret({ secret }).then(sendToKMS); +}; + +/** + * Patch secret in Clever KMS + * @param {string} secret + * @param {Array} data - key=value pairs + * @returns {Promise} secret data + */ +export const patchSecret = (secret, data) => { + return KMS.patchSecret({ secret }, { + options: { cas: 0 }, + data: checkKeyValues(data), + }).then(sendToKMS); +}; + +/** + * Put secret in Clever KMS + * @param {string} secret + * @param {Array} data - key=value pairs + * @returns {Promise} secret data + */ +export const putSecret = (secret, data) => { + return KMS.putSecret({ secret }, { + options: { cas: 0 }, + data: checkKeyValues(data), + }).then(sendToKMS); +}; + +/** + * Check key=value pairs + * @param {Array} keyValues + * @returns {object} valid key=value pairs + */ +export const checkKeyValues = (keyValues) => { + const parsed = {}; + + for (const kv of keyValues) { + const [key, value] = kv.split('='); + if (!value) { + console.error(`${colors.yellow('[WARNING]')} Invalid format: ${colors.yellow(kv)}, expected key=value`); + continue; + } + parsed[key] = value; + } + return parsed; +}; + +/** + * Send a request to Clever KMS + * @param {object} request - request object for fetch + * @returns {Promise} response data + * @throws {Error} if the request fails + * @throws {Error} if the secret is not found + */ +export async function sendToKMS (request) { + + request.url = `${conf.KMS_HOST}${request.url}`; + request.headers['X-Vault-Token'] = process.env.VAULT_TOKEN; + + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: JSON.stringify(request.body), + }); + + if (response.status === 404) { + throw new Error(`Secret not found: ${colors.red(request.url)}`); + } + + if (!response.ok) { + throw new Error(`Request failed (${response.status}): ${response.statusText}`); + } + + return response.json(); +};