From 1652662e2c3cf93ee83147cd91f0a266caae3d19 Mon Sep 17 00:00:00 2001 From: Trent Mick Date: Wed, 4 Nov 2015 00:11:19 -0800 Subject: [PATCH] joyent/node-triton#54 Complete first pass at 'triton rbac user' and 'triton rbac users' --- CHANGES.md | 2 + lib/cloudapi2.js | 117 +++++++++++ lib/common.js | 25 ++- lib/do_profile.js | 1 - lib/do_rbac/do_user.js | 462 +++++++++++++++++++++++++++++++++++++++-- 5 files changed, 581 insertions(+), 26 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 6eafd97..db02aa1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,8 @@ - `triton profiles` now shows the optional `user` fields. - A (currently experimental and hidden) `triton rbac ...` command to house RBAC CLI functionality. + - `triton rbac users` to list all users. + - `triton rbac user ...` to show, create, edit and delete users. ## 2.1.4 diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index ff829d0..4fbc36f 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -710,6 +710,123 @@ CloudApi.prototype.getUser = function getUser(opts, cb) { this._passThrough(endpoint, {membership: opts.membership}, cb); }; +/** + * + * + * @param {String} account (optional) the login name of the account. + * @param {Object} opts (object) user object containing: + * - {String} login (required) for your user. + * - {String} password (required) for the user. + * - {String} email (required) for the user. + * - {String} companyName (optional) for the user. + * - {String} firstName (optional) for the user. + * - {String} lastName (optional) for the user. + * - {String} address (optional) for the user. + * - {String} postalCode (optional) for the user. + * - {String} city (optional) for the user. + * - {String} state (optional) for the user. + * - {String} country (optional) for the user. + * - {String} phone (optional) for the user. + * @param {Function} cb of the form `function (err, user, res)` + */ +CloudApi.prototype.createUser = function createUser(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.login, 'opts.login'); + assert.string(opts.password, 'opts.password'); + assert.string(opts.email, 'opts.email'); + // XXX strict on inputs + assert.func(cb, 'cb'); + + var data = { + login: opts.login, + password: opts.password, + email: opts.email, + companyName: opts.companyName, + firstName: opts.firstName, + lastName: opts.lastName, + address: opts.address, + postalCode: opts.postalCode, + city: opts.city, + state: opts.state, + country: opts.country, + phone: opts.phone + }; + + this._request({ + method: 'POST', + path: format('/%s/users', this.account), + data: data + }, function (err, req, res, body) { + cb(err, body, res); + }); +}; + +/** + * + * + * @param {Object} opts (object) user object containing: + * - {String} id (required) for your user. + * - {String} email (optional) for the user. + * - {String} companyName (optional) for the user. + * - {String} firstName (optional) for the user. + * - {String} lastName (optional) for the user. + * - {String} address (optional) for the user. + * - {String} postalCode (optional) for the user. + * - {String} city (optional) for the user. + * - {String} state (optional) for the user. + * - {String} country (optional) for the user. + * - {String} phone (optional) for the user. + * @param {Function} cb of the form `function (err, user, res)` + */ +CloudApi.prototype.updateUser = function updateUser(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + // XXX strict on inputs + assert.func(cb, 'cb'); + + var update = { + email: opts.email, + companyName: opts.companyName, + firstName: opts.firstName, + lastName: opts.lastName, + address: opts.address, + postalCode: opts.postalCode, + city: opts.city, + state: opts.state, + country: opts.country, + phone: opts.phone + }; + + this._request({ + method: 'POST', + path: format('/%s/users/%s', this.account, opts.id), + data: update + }, function (err, req, res, body) { + cb(err, body, res); + }); +}; + + +/** + * + * + * @param {Object} opts (object) user object containing: + * - {String} id (required) for your user. + * @param {Function} cb of the form `function (err, user, res)` + */ +CloudApi.prototype.deleteUser = function deleteUser(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.func(cb, 'cb'); + + this._request({ + method: 'DELETE', + path: format('/%s/users/%s', this.account, opts.id) + }, function (err, req, res, body) { + cb(err, body, res); + }); +}; + // --- Exports diff --git a/lib/common.js b/lib/common.js index d99f59e..6c30010 100644 --- a/lib/common.js +++ b/lib/common.js @@ -560,6 +560,9 @@ function promptEnter(prompt, cb) { * cb(new Error('value is not a number')); * cb(); // value is fine as is * cb(null, Math.floor(Number(value))); // manip to a floored int + * - field.required {Boolean} Optional. If `field.validate` is not + * given, `required=true` will provide a validate func that requires + * a value. * @params cb {Function} `function (err, value)` * If the user aborted, the `err` will be whatever the [read * package](https://www.npmjs.com/package/read) returns, i.e. a @@ -567,22 +570,36 @@ function promptEnter(prompt, cb) { */ function promptField(field, cb) { var wrap = wordwrap(Math.min(process.stdout.columns, 78)); + + var validate = field.validate; + if (!validate && field.required) { + validate = function (value, validateCb) { + if (!value) { + validateCb(new Error(format('A value for "%s" is required.', + field.key))); + } else { + validateCb(); + } + }; + } + function attempt(next) { read({ // read/readline prompting messes up width with ANSI codes here. prompt: field.key + ':', default: field.default, + silent: field.password, edit: true }, function (err, result, isDefault) { if (err) { return cb(err); } var value = result.trim(); - if (!field.validate) { + if (!validate) { return cb(null, value); } - field.validate(value, function (validationErr, newValue) { + validate(value, function (validationErr, newValue) { if (validationErr) { console.log(ansiStylize( wrap(validationErr.message), 'red')); @@ -597,7 +614,9 @@ function promptField(field, cb) { }); } - console.log(ansiStylize(wrap(field.desc), 'bold')); + if (field.desc) { + console.log(ansiStylize(wrap(field.desc), 'bold')); + } attempt(); } diff --git a/lib/do_profile.js b/lib/do_profile.js index d694c66..82f7149 100644 --- a/lib/do_profile.js +++ b/lib/do_profile.js @@ -7,7 +7,6 @@ var assert = require('assert-plus'); var format = require('util').format; var fs = require('fs'); -var read = require('read'); var strsplit = require('strsplit'); var sshpk = require('sshpk'); var tilde = require('tilde-expansion'); diff --git a/lib/do_rbac/do_user.js b/lib/do_rbac/do_user.js index abbbcdc..c4d6fbb 100644 --- a/lib/do_rbac/do_user.js +++ b/lib/do_rbac/do_user.js @@ -10,21 +10,55 @@ * `triton rbac user ...` */ +var assert = require('assert-plus'); +var format = require('util').format; +var fs = require('fs'); +var strsplit = require('strsplit'); +var vasync = require('vasync'); + +var common = require('../common'); var errors = require('../errors'); +var UPDATABLE_USER_FIELDS = [ + {key: 'email', required: true}, + {key: 'firstName'}, + {key: 'lastName'}, + {key: 'companyName'}, + {key: 'address'}, + {key: 'postalCode'}, + {key: 'city'}, + {key: 'state'}, + {key: 'country'}, + {key: 'phone'} +]; -function do_user(subcmd, opts, args, cb) { - if (opts.help) { - this.do_help('help', {}, [subcmd], cb); - return; - } else if (args.length !== 1) { - return cb(new errors.UsageError('incorrect number of args')); - } +var CREATE_USER_FIELDS = [ + {key: 'login', required: true}, + {key: 'password', password: true, required: true}, + {key: 'email', required: true}, + {key: 'firstName'}, + {key: 'lastName'}, + {key: 'companyName'}, + {key: 'address'}, + {key: 'postalCode'}, + {key: 'city'}, + {key: 'state'}, + {key: 'country'}, + {key: 'phone'} +]; - this.top.tritonapi.getUser({ - id: args[0], - roles: opts.roles || opts.membership + +function _showUser(opts, cb) { + assert.object(opts.cli, 'opts.cli'); + assert.string(opts.id, 'opts.id'); + assert.optionalBool(opts.roles, 'opts.roles'); + assert.func(cb, 'cb'); + var cli = opts.cli; + + cli.tritonapi.getUser({ + id: opts.id, + roles: opts.roles }, function onUser(err, user) { if (err) { return cb(err); @@ -33,12 +67,362 @@ function do_user(subcmd, opts, args, cb) { if (opts.json) { console.log(JSON.stringify(user)); } else { - console.log(JSON.stringify(user, null, 4)); + Object.keys(user).forEach(function (key) { + console.log('%s: %s', key, user[key]); + }); } cb(); }); } +function _yamlishFromUser(user) { + assert.object(user, 'user'); + + var lines = []; + UPDATABLE_USER_FIELDS.forEach(function (field) { + lines.push(format('%s: %s', field.key, user[field.key] || '')); + }); + return lines.join('\n') + '\n'; +} + +function _userFromYamlish(yamlish) { + assert.string(yamlish, 'yamlish'); + + var user = {}; + var lines = yamlish.split(/\n/g); + lines.forEach(function (line) { + var commentIdx = line.indexOf('#'); + if (commentIdx !== -1) { + line = line.slice(0, commentIdx); + } + line = line.trim(); + if (!line) { + return; + } + var parts = strsplit(line, ':', 2); + var key = parts[0].trim(); + var value = parts[1].trim(); + user[key] = value; + }); + + return user; +} + + +function _editUser(opts, cb) { + assert.object(opts.cli, 'opts.cli'); + assert.string(opts.id, 'opts.id'); + assert.func(cb, 'cb'); + var cli = opts.cli; + + var user; + var filename; + var origText; + + function offerRetry(afterText) { + common.promptEnter( + 'Press to re-edit, Ctrl+C to abort.', + function (aborted) { + if (aborted) { + console.log('\nAborting. No change made to user.'); + cb(); + } else { + editAttempt(afterText); + } + }); + } + + function editAttempt(text) { + common.editInEditor({ + text: text, + filename: filename + }, function (err, afterText, changed) { + if (err) { + return cb(new errors.TritonError(err)); + } + // We don't use this `changed` in case it is a second attempt. + + try { + var editedUser = _userFromYamlish(afterText); + editedUser.id = user.id; + + if (_yamlishFromUser(editedUser) === origText) { + // This YAMLish is the closest to a canonical form we have. + console.log('No change to user'); + cb(); + return; + } + } catch (textErr) { + console.error('Error with your changes: %s', textErr); + offerRetry(afterText); + return; + } + + // Save changes. + cli.tritonapi.cloudapi.updateUser(editedUser, function (uErr, uu) { + if (uErr) { + console.error('Error updating user with your changes: %s', + uErr); + offerRetry(afterText); + return; + } + console.log('Updated user "%s"', uu.login); + cb(); + }); + }); + } + + + cli.tritonapi.getUser({ + id: opts.id, + roles: opts.roles + }, function onUser(err, user_) { + if (err) { + return cb(err); + } + + user = user_; + filename = format('user-%s-%s.txt', cli.account, user.login); + origText = _yamlishFromUser(user); + editAttempt(origText); + }); +} + + +function _deleteUsers(opts, cb) { + assert.object(opts.cli, 'opts.cli'); + assert.arrayOfString(opts.ids, 'opts.ids'); + assert.optionalBool(opts.yes, 'opts.yes'); + assert.func(cb, 'cb'); + var cli = opts.cli; + + if (opts.ids.length === 0) { + cb(); + return; + } + + vasync.pipeline({funcs: [ + function confirm(_, next) { + if (opts.yes) { + return next(); + } + var msg; + if (opts.ids.length === 1) { + msg = 'Delete user "' + opts.ids[0] + '"? [y/n] '; + } else { + msg = 'Delete %d users (' + opts.ids.join(', ') + ')? [y/n] '; + + } + common.promptYesNo({msg: msg}, function (answer) { + if (answer !== 'y') { + console.error('Aborting'); + next(true); // early abort signal + } else { + next(); + } + }); + }, + function deleteThem(_, next) { + vasync.forEachPipeline({ + inputs: opts.ids, + func: function deleteOne(id, nextId) { + cli.tritonapi.cloudapi.deleteUser({id: id}, function (err) { + if (err) { + nextId(err); + return; + } + console.log('Deleted user "%s"', id); + nextId(); + }); + } + }, next); + } + ]}, function (err) { + if (err === true) { + err = null; + } + cb(err); + }); +} + + +function _addUser(opts, cb) { + assert.object(opts.cli, 'opts.cli'); + assert.optionalString(opts.file, 'opts.file'); + assert.func(cb, 'cb'); + var cli = opts.cli; + var log = cli.log; + + var data; + + vasync.pipeline({funcs: [ + function gatherDataStdin(_, next) { + if (opts.file !== '-') { + return next(); + } + var stdin = ''; + process.stdin.resume(); + process.stdin.on('data', function (chunk) { + stdin += chunk; + }); + process.stdin.on('end', function () { + try { + data = JSON.parse(stdin); + } catch (err) { + log.trace({stdin: stdin}, 'invalid user JSON on stdin'); + return next(new errors.TritonError( + format('invalid user JSON on stdin: %s', err))); + } + next(); + }); + }, + function gatherDataFile(_, next) { + if (!opts.file || opts.file === '-') { + return next(); + } + var input = fs.readFileSync(opts.file); + try { + data = JSON.parse(input); + } catch (err) { + return next(new errors.TritonError(format( + 'invalid user JSON in "%s": %s', opts.file, err))); + } + next(); + }, + function gatherDataInteractive(_, next) { + if (opts.file) { + return next(); + } else if (!process.stdin.isTTY) { + return next(new errors.UsageError('cannot interactively ' + + 'create a user: stdin is not a TTY')); + } else if (!process.stdout.isTTY) { + return next(new errors.UsageError('cannot interactively ' + + 'create a user: stdout is not a TTY')); + } + + // TODO: confirm password + // TODO: some validation on login, email, password complexity + // TODO: retries on failure + // TODO: on failure write out to a tmp file with cmd to add it + data = {}; + vasync.forEachPipeline({ + inputs: CREATE_USER_FIELDS, + func: function getField(field, nextField) { + common.promptField(field, function (err, value) { + if (value) { + data[field.key] = value; + } + nextField(err); + }); + } + }, function (err) { + console.log(); + next(err); + }); + }, + function validateData(_, next) { + var missing = []; + var dataCopy = common.objCopy(data); + CREATE_USER_FIELDS.forEach(function (field) { + if (dataCopy.hasOwnProperty(field.key)) { + delete dataCopy[field.key]; + } else if (field.required) { + missing.push(field.key); + } + }); + var extra = Object.keys(dataCopy); + var issues = []; + if (missing.length) { + issues.push(format('%s missing required field%s: %s', + missing.length, (missing.length === 1 ? '' : 's'), + missing.join(', '))); + } + if (extra.length) { + issues.push(format('extraneous field%s: %s', + (extra.length === 1 ? '' : 's'), extra.join(', '))); + } + if (issues.length) { + next(new errors.TritonError( + 'invalid user data: ' + issues.join('; '))); + } else { + next(); + } + }, + function createIt(_, next) { + cli.tritonapi.cloudapi.createUser(data, function (err, user) { + if (err) { + next(err); + return; + } + console.log('Created user "%s"', user.login); + next(); + }); + } + ]}, cb); +} + + +function do_user(subcmd, opts, args, cb) { + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } + + // Which action? + var actions = []; + if (opts.add) { actions.push('add'); } + if (opts.edit) { actions.push('edit'); } + if (opts['delete']) { actions.push('delete'); } + var action; + if (actions.length === 0) { + action = 'show'; + } else if (actions.length > 1) { + return cb(new errors.UsageError( + 'only one action option may be used at once')); + } else { + action = actions[0]; + } + + // Arg count validation. + if (args.length > 1) { + return cb(new errors.UsageError('too many arguments')); + } else if (args.length === 0 && + ['show', 'edit'].indexOf(action) !== -1) + { + return cb(new errors.UsageError('USER argument is required')); + } + + switch (action) { + case 'show': + _showUser({ + cli: this.top, + id: args[0], + roles: opts.roles || opts.membership, + json: opts.json + }, cb); + break; + case 'edit': + // TODO: support `triton rbac user trent -e companyName=Tuna` k=v args + _editUser({ + cli: this.top, + id: args[0] + }, cb); + break; + case 'delete': + _deleteUsers({ + cli: this.top, + ids: args, + yes: opts.yes + }, cb); + break; + case 'add': + _addUser({cli: this.top, file: args[0]}, cb); + break; + default: + return cb(new errors.InternalError('unknown action: ' + action)); + } +} + do_user.options = [ { names: ['help', 'h'], @@ -62,20 +446,54 @@ do_user.options = [ 'for backward compat with `sdc-user get --membership ...` from ' + 'node-smartdc.', hidden: true + }, + { + names: ['yes', 'y'], + type: 'bool', + help: 'Answer yes to confirmations, e.g. confirmation of deletion.' + }, + { + group: 'Action Options' + }, + { + names: ['edit', 'e'], + type: 'bool', + help: 'Edit the named user in your $EDITOR.' + }, + { + names: ['add', 'a'], + type: 'bool', + help: 'Add a new user.' + }, + { + names: ['delete', 'd'], + type: 'bool', + help: 'Delete the named user.' } ]; -do_user.help = ( +do_user.help = [ /* BEGIN JSSTYLED */ - 'Get an RBAC user.\n' + - '\n' + - 'Usage:\n' + - ' {{name}} user [] ID|LOGIN|SHORT-ID\n' + - '\n' + - '{{options}}' + - '\n' + - 'Note: Currently this dumps indented JSON by default. That might change\n' + - 'in the future. Use "-j" to explicitly get JSON output.\n' + 'Show, add, edit and delete RBAC users.', + '', + 'Usage:', + ' {{name}} user USER # show user USER', + ' {{name}} user -e|--edit USER # edit user USER in $EDITOR', + ' {{name}} user -d|--delete [USER...] # delete user USER', + '', + ' {{name}} user -a|--add [FILE]', + ' # Add a new user. FILE must be a file path to a JSON file', + ' # with the user data or "-" to pass the user in on stdin.', + ' # Or exclude FILE to interactively add.', + '', + '{{options}}', + 'Where "USER" is a full user "id", the user "login" name or a "shortid", i.e.', + 'an id prefix.', + '', + 'Fields for creating a user:', + CREATE_USER_FIELDS.map(function (field) { + return ' ' + field.key + (field.required ? ' (required)' : ''); + }).join('\n') /* END JSSTYLED */ -); +].join('\n'); module.exports = do_user;