From 8a46d232683b17c5fb07f0da34b8def93f891a81 Mon Sep 17 00:00:00 2001 From: Trent Mick Date: Tue, 3 Nov 2015 15:40:59 -0800 Subject: [PATCH] joyent/node-triton#54 A start at RBAC support (still very early) --- CHANGES.md | 11 ++- lib/cli.js | 21 ++--- lib/cloudapi2.js | 174 +++++++++++++++++++++++++++------------- lib/common.js | 2 +- lib/config.js | 5 +- lib/do_keys.js | 2 +- lib/do_profile.js | 6 +- lib/do_profiles.js | 4 +- lib/do_rbac/do_user.js | 81 +++++++++++++++++++ lib/do_rbac/do_users.js | 112 ++++++++++++++++++++++++++ lib/do_rbac/index.js | 47 +++++++++++ lib/tritonapi.js | 136 ++++++++++++++++++++++++++++++- package.json | 2 +- 13 files changed, 525 insertions(+), 78 deletions(-) create mode 100644 lib/do_rbac/do_user.js create mode 100644 lib/do_rbac/do_users.js create mode 100644 lib/do_rbac/index.js diff --git a/CHANGES.md b/CHANGES.md index 0ce7035..6eafd97 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,8 +1,15 @@ # node-triton changelog -## 2.1.5 (not yet released) +## 3.0.0 (not yet released) -(nothing yet) +- #54 RBAC support, see to start. + - [Backward incompatible.] The `triton` CLI option for the cloudapi URL has + changed from `--url,-u` to **`--url,-U`**. + - Add `triton --user,-u USER` CLI option and `TRITON_USER` (or `SDC_USER`) + environment variable support for specifying the RBAC user. + - `triton profiles` now shows the optional `user` fields. + - A (currently experimental and hidden) `triton rbac ...` command to + house RBAC CLI functionality. ## 2.1.4 diff --git a/lib/cli.js b/lib/cli.js index 389de86..c381d28 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -84,14 +84,14 @@ var OPTIONS = [ 'or SDC_ACCOUNT=ACCOUNT.', helpArg: 'ACCOUNT' }, - // TODO: subuser/RBAC support - //{ - // names: ['subuser', 'user'], - // type: 'string', - // env: 'MANTA_SUBUSER', - // help: 'Manta User (login name)', - // helpArg: 'USER' - //}, + { + names: ['user', 'u'], + type: 'string', + help: 'RBAC user (login name). Environment: TRITON_USER=USER ' + + 'or SDC_USER=USER.', + helpArg: 'USER' + }, + // TODO: full rbac support //{ // names: ['role'], // type: 'arrayOfString', @@ -107,7 +107,7 @@ var OPTIONS = [ helpArg: 'FINGERPRINT' }, { - names: ['url', 'u'], + names: ['url', 'U'], type: 'string', help: 'CloudAPI URL. Environment: TRITON_URL=URL or SDC_URL=URL.', helpArg: 'URL' @@ -253,7 +253,7 @@ CLI.prototype.init = function (opts, args, callback) { CLI.prototype._applyProfileOverrides = function _applyProfileOverrides(profile) { var self = this; - ['account', 'url', 'keyId', 'insecure'].forEach(function (field) { + ['account', 'user', 'url', 'keyId', 'insecure'].forEach(function (field) { // We need to check `opts._order` to know if boolean opts // were specified. var specified = self.opts._order.filter( @@ -305,6 +305,7 @@ CLI.prototype.do_network = require('./do_network'); // Hidden commands CLI.prototype.do_cloudapi = require('./do_cloudapi'); CLI.prototype.do_badger = require('./do_badger'); +CLI.prototype.do_rbac = require('./do_rbac'); diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index 146a7c6..ff829d0 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -16,11 +16,15 @@ * var cloudapi = require('./lib/cloudapi2'); * var client = cloudapi.createClient({ * url: , // 'https://us-sw-1.api.joyent.com', - * user: , // 'bob' + * account: , // 'acmecorp' + * [user: ,] // 'bob' * log: , * sign: auth.cliSigner({ * keyId: , // ssh fingerprint - * user: , // 'bob' + * // Unfortunately node-smartdc-auth uses user/subuser, while + * // node-triton uses account/user: + * user: , // 'acmecorp' + * [subuser: ,] // 'bob' * log: , * }), * ... @@ -72,9 +76,9 @@ BunyanNoopLogger.prototype.end = function () {}; * * @param options {Object} * - {String} url (required) Cloud API base url - * - {String} user (required) The user login name. - * For backward compat, 'options.account' is accepted as a synonym. + * - {String} account (required) The account login name. * - {Function} sign (required) An http-signature auth signing function + * - {String} user (optional) The RBAC user login name. * - {String} version (optional) Used for the accept-version header. This * defaults to '*', meaning that over time you could experience breaking * changes. Specifying a value is strongly recommended. E.g. '~7.1'. @@ -96,13 +100,15 @@ BunyanNoopLogger.prototype.end = function () {}; function CloudApi(options) { assert.object(options, 'options'); assert.string(options.url, 'options.url'); - assert.string(options.user || options.account, 'options.user'); + assert.string(options.account, 'options.account'); assert.func(options.sign, 'options.sign'); + assert.optionalString(options.user, 'options.user'); assert.optionalString(options.version, 'options.version'); assert.optionalObject(options.log, 'options.log'); this.url = options.url; - this.user = options.user || options.account; + this.account = options.account; + this.user = options.user; // optional RBAC subuser this.sign = options.sign; this.log = options.log || new BunyanNoopLogger(); if (!options.version) { @@ -118,7 +124,7 @@ function CloudApi(options) { // return (code === 500); //}; - // XXX relevant? + // TODO support token auth //this.token = options.token; this.client = new SaferJsonClient(options); @@ -133,7 +139,7 @@ CloudApi.prototype._getAuthHeaders = function _getAuthHeaders(callback) { headers.date = new Date().toUTCString(); var sigstr = 'date: ' + headers.date; - //XXX + // TODO: token auth support //if (this.token !== undefined) { // obj.headers['X-Auth-Token'] = this.token; //} @@ -144,9 +150,13 @@ CloudApi.prototype._getAuthHeaders = function _getAuthHeaders(callback) { return; } + var ident = self.account; + if (self.user) { + ident += '/users/' + self.user; + } headers.authorization = format( 'Signature keyId="/%s/keys/%s",algorithm="%s",signature="%s"', - self.user, sig.keyId, sig.algorithm, sig.signature); + ident, sig.keyId, sig.algorithm, sig.signature); callback(null, headers); }); }; @@ -178,7 +188,7 @@ CloudApi.prototype._qs = function _qs(/* fields1, ...*/) { /** - * Return an appropriate full URL *path* given an CloudApi subpath. + * Return an appropriate full URL *path* given a CloudApi subpath. * This handles prepending the API's base path, if any: e.g. if the configured * URL is "https://example.com/base/path". * @@ -244,8 +254,7 @@ CloudApi.prototype._request = function _request(options, callback) { * A simple wrapper around making a GET request to an endpoint and * passing back the body returned */ -CloudApi.prototype._passThrough = -function _passThrough(endpoint, opts, cb) { +CloudApi.prototype._passThrough = function _passThrough(endpoint, opts, cb) { var self = this; if (typeof (opts) === 'function') { cb = opts; @@ -278,6 +287,8 @@ function _passThrough(endpoint, opts, cb) { }); }; + + // ---- networks /** @@ -286,7 +297,7 @@ function _passThrough(endpoint, opts, cb) { * @param {Function} callback of the form `function (err, networks, response)` */ CloudApi.prototype.listNetworks = function listNetworks(opts, cb) { - var endpoint = format('/%s/networks', this.user); + var endpoint = format('/%s/networks', this.account); this._passThrough(endpoint, opts, cb); }; @@ -300,12 +311,14 @@ CloudApi.prototype.getNetwork = function getNetwork(id, cb) { assert.uuid(id, 'id'); assert.func(cb, 'cb'); - var endpoint = this._path(format('/%s/networks/%s', this.user, id)); + var endpoint = this._path(format('/%s/networks/%s', this.account, id)); this._request(endpoint, function (err, req, res, body) { cb(err, body, res); }); }; + + // ---- datacenters /** @@ -314,7 +327,7 @@ CloudApi.prototype.getNetwork = function getNetwork(id, cb) { * @param {Function} callback of the form `function (err, services, response)` */ CloudApi.prototype.listServices = function listServices(opts, cb) { - var endpoint = format('/%s/services', this.user); + var endpoint = format('/%s/services', this.account); this._passThrough(endpoint, opts, cb); }; @@ -325,10 +338,11 @@ CloudApi.prototype.listServices = function listServices(opts, cb) { * `function (err, datacenters, response)` */ CloudApi.prototype.listDatacenters = function listDatacenters(opts, cb) { - var endpoint = format('/%s/datacenters', this.user); + var endpoint = format('/%s/datacenters', this.account); this._passThrough(endpoint, opts, cb); }; + // ---- accounts /** @@ -337,7 +351,7 @@ CloudApi.prototype.listDatacenters = function listDatacenters(opts, cb) { * @param {Function} callback of the form `function (err, account, response)` */ CloudApi.prototype.getAccount = function getAccount(opts, cb) { - var endpoint = format('/%s', this.user); + var endpoint = format('/%s', this.account); this._passThrough(endpoint, opts, cb); }; @@ -347,68 +361,76 @@ CloudApi.prototype.getAccount = function getAccount(opts, cb) { * @param {Function} callback of the form `function (err, keys, response)` */ CloudApi.prototype.listKeys = function listKeys(opts, cb) { - var endpoint = format('/%s/keys', this.user); + var endpoint = format('/%s/keys', this.account); this._passThrough(endpoint, opts, cb); }; + // ---- images /** * * - * @param {Object} options (optional) + * @param {Object} opts (optional) * XXX be more strict about accepted options * XXX document this, see the api doc above :) - * @param {Function} callback of the form `function (err, images, res)` + * @param {Function} cb of the form `function (err, images, res)` */ CloudApi.prototype.listImages = function listImages(opts, cb) { - var endpoint = format('/%s/images', this.user); + var endpoint = format('/%s/images', this.account); this._passThrough(endpoint, opts, cb); }; - /** - * + * * - * @param {Object} options + * @param {Object} opts * - id {UUID} - * @param {Function} callback of the form `function (err, image, res)` + * @param {Function} cb of the form `function (err, image, res)` */ -CloudApi.prototype.getImage = function getImage(options, callback) { - if (callback === undefined) { - callback = options; - options = {}; - } - assert.object(options, 'options'); - assert.uuid(options.id, 'options.id'); - assert.func(callback, 'callback'); +CloudApi.prototype.getImage = function getImage(opts, cb) { + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.func(cb, 'cb'); - var endpoint = this._path(format('/%s/images/%s', this.user, options.id)); + var endpoint = this._path(format('/%s/images/%s', this.account, opts.id)); this._request(endpoint, function (err, req, res, body) { - callback(err, body, res); + cb(err, body, res); }); }; // ---- packages +/** + * + * + * @param {Object} opts (optional) + * XXX be more strict about accepted options + * XXX document this, see the api doc above :) + * @param {Function} callback of the form `function (err, packages, res)` + */ CloudApi.prototype.listPackages = function listPackages(opts, cb) { - var endpoint = format('/%s/packages', this.user); + var endpoint = format('/%s/packages', this.account); this._passThrough(endpoint, opts, cb); }; -CloudApi.prototype.getPackage = function getPackage(options, callback) { - if (callback === undefined) { - callback = options; - options = {}; - } - assert.object(options, 'options'); - assert.uuid(options.id, 'options.id'); - assert.func(callback, 'callback'); +/** + * + * + * @param {Object} opts + * - id {UUID|String} Package ID (a UUID) or name. + * @param {Function} cb of the form `function (err, package, res)` + */ +CloudApi.prototype.getPackage = function getPackage(opts, cb) { + assert.object(opts, 'opts'); + assert.uuid(opts.id, 'opts.id'); + assert.func(cb, 'cb'); - var endpoint = this._path(format('/%s/packages/%s', this.user, options.id)); + // XXX use _passThrough? + var endpoint = this._path(format('/%s/packages/%s', this.account, opts.id)); this._request(endpoint, function (err, req, res, body) { - callback(err, body, res); + cb(err, body, res); }); }; @@ -428,7 +450,7 @@ CloudApi.prototype.getMachine = function getMachine(id, cb) { assert.uuid(id, 'id'); assert.func(cb, 'cb'); - var endpoint = format('/%s/machines/%s', this.user, id); + var endpoint = format('/%s/machines/%s', this.account, id); this._request(endpoint, function (err, req, res, body) { cb(err, body, res); }); @@ -446,7 +468,7 @@ CloudApi.prototype.deleteMachine = function deleteMachine(uuid, callback) { assert.func(callback, 'callback'); var opts = { - path: format('/%s/machines/%s', self.user, uuid), + path: format('/%s/machines/%s', self.account, uuid), method: 'DELETE' }; this._request(opts, function (err, req, res, body) { @@ -494,7 +516,7 @@ CloudApi.prototype._doMachine = function _doMachine(action, uuid, callback) { assert.func(callback, 'callback'); var opts = { - path: format('/%s/machines/%s', self.user, uuid), + path: format('/%s/machines/%s', self.account, uuid), method: 'POST', data: { action: action @@ -542,7 +564,7 @@ CloudApi.prototype.waitForMachineStates = }; /** - * List the user's machines. + * List the account's machines. * * * @param {Object} options @@ -554,7 +576,7 @@ function createListMachinesStream(options) { var self = this; options = options || {}; - // if the user specifies an offset we don't paginate + // If a `limit` is specified, we don't paginate. var once = options.limit !== undefined; return new LOMStream({ @@ -566,7 +588,8 @@ function createListMachinesStream(options) { function fetch(fetcharg, limitObj, datacb, donecb) { options.limit = limitObj.limit; options.offset = limitObj.offset; - var endpoint = self._path(format('/%s/machines', self.user), options); + var endpoint = self._path( + format('/%s/machines', self.account), options); self._request(endpoint, function (err, req, res, body) { if (err) { @@ -580,7 +603,7 @@ function createListMachinesStream(options) { }; /** - * List the user's machines. + * List the account's machines. * * * @param {Object} options @@ -621,7 +644,7 @@ CloudApi.prototype.createMachine = function createMachine(options, callback) { // XXX how does options.networks array work here? this._request({ method: 'POST', - path: format('/%s/machines', this.user), + path: format('/%s/machines', this.account), data: options }, function (err, req, res, body) { callback(err, body, res); @@ -641,13 +664,54 @@ CloudApi.prototype.machineAudit = function machineAudit(id, cb) { assert.uuid(id, 'id'); assert.func(cb, 'cb'); - var endpoint = format('/%s/machines/%s/audit', this.user, id); + var endpoint = format('/%s/machines/%s/audit', this.account, id); this._request(endpoint, function (err, req, res, body) { cb(err, body, res); }); }; +// --- rbac + +/** + * + * + * @param opts {Object} Options (optional) + * @param cb {Function} Callback of the form `function (err, users, res)` + */ +CloudApi.prototype.listUsers = function listUsers(opts, cb) { + if (cb === undefined) { + cb = opts; + opts = {}; + } + assert.func(cb, 'cb'); + assert.object(opts, 'opts'); + + var endpoint = format('/%s/users', this.account); + this._passThrough(endpoint, opts, cb); +}; + +/** + * + * + * @param {Object} opts + * - id {UUID|String} The user ID or login. + * - membership {Boolean} Optional. Whether to includes roles of which + * this user is a member. Default false. + * @param {Function} callback of the form `function (err, user, res)` + */ +CloudApi.prototype.getUser = function getUser(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.optionalBool(opts.membership, 'opts.membership'); + assert.func(cb, 'cb'); + + var endpoint = format('/%s/users/%s', this.account, opts.id); + this._passThrough(endpoint, {membership: opts.membership}, cb); +}; + + + // --- Exports module.exports.createClient = function (options) { diff --git a/lib/common.js b/lib/common.js index 5614d32..d99f59e 100644 --- a/lib/common.js +++ b/lib/common.js @@ -318,7 +318,7 @@ function normShortId(s) { return; } shortId += head; - if (remaining) { + if (remaining && i + 1 < spans.length) { shortId += '-'; } else { break; diff --git a/lib/config.js b/lib/config.js index f241fdb..6b88b1b 100644 --- a/lib/config.js +++ b/lib/config.js @@ -58,7 +58,8 @@ var PROFILE_FIELDS = { url: true, account: true, keyId: true, - insecure: true + insecure: true, + user: true }; @@ -190,6 +191,7 @@ function validateProfile(profile, profilePath) { assert.string(profile.account, 'profile.account'); assert.string(profile.keyId, 'profile.keyId'); assert.optionalBool(profile.insecure, 'profile.insecure'); + assert.optionalString(profile.user, 'profile.user'); } catch (err) { throw new errors.ConfigError(err.message); } @@ -224,6 +226,7 @@ function _loadEnvProfile() { }; envProfile.account = process.env.TRITON_ACCOUNT || process.env.SDC_ACCOUNT; + envProfile.user = process.env.TRITON_USER || process.env.SDC_USER; envProfile.url = process.env.TRITON_URL || process.env.SDC_URL; envProfile.keyId = process.env.TRITON_KEY_ID || process.env.SDC_KEY_ID; if (process.env.TRITON_TLS_INSECURE) { diff --git a/lib/do_keys.js b/lib/do_keys.js index d6f4271..22f85f7 100644 --- a/lib/do_keys.js +++ b/lib/do_keys.js @@ -7,7 +7,7 @@ /* * Copyright 2015 Joyent, Inc. * - * `triton account ...` + * `triton keys ...` */ var common = require('./common'); diff --git a/lib/do_profile.js b/lib/do_profile.js index 0b27cb0..d694c66 100644 --- a/lib/do_profile.js +++ b/lib/do_profile.js @@ -44,9 +44,11 @@ function _showProfile(opts, cb) { if (opts.json) { console.log(JSON.stringify(profile)); } else { + console.log('name: %s', profile.name); Object.keys(profile).sort().forEach(function (key) { - var val = profile[key]; - console.log('%s: %s', key, val); + if (key === 'name') + return; + console.log('%s: %s', key, profile[key]); }); } } diff --git a/lib/do_profiles.js b/lib/do_profiles.js index ccdf875..ddf2653 100644 --- a/lib/do_profiles.js +++ b/lib/do_profiles.js @@ -11,8 +11,8 @@ var tabula = require('tabula'); var sortDefault = 'name'; -var columnsDefault = 'name,curr,account,url'; -var columnsDefaultLong = 'name,curr,account,url,insecure,keyId'; +var columnsDefault = 'name,curr,account,user,url'; +var columnsDefaultLong = 'name,curr,account,user,url,insecure,keyId'; function _listProfiles(cli, opts, args, cb) { var columns = columnsDefault; diff --git a/lib/do_rbac/do_user.js b/lib/do_rbac/do_user.js new file mode 100644 index 0000000..abbbcdc --- /dev/null +++ b/lib/do_rbac/do_user.js @@ -0,0 +1,81 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright 2015 Joyent, Inc. + * + * `triton rbac user ...` + */ + +var errors = require('../errors'); + + + +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')); + } + + this.top.tritonapi.getUser({ + id: args[0], + roles: opts.roles || opts.membership + }, function onUser(err, user) { + if (err) { + return cb(err); + } + + if (opts.json) { + console.log(JSON.stringify(user)); + } else { + console.log(JSON.stringify(user, null, 4)); + } + cb(); + }); +} + +do_user.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON stream output.' + }, + { + names: ['roles', 'r'], + type: 'bool', + help: 'Include "roles" and "default_roles" this user has.' + }, + { + names: ['membership'], + type: 'bool', + help: 'Include "roles" and "default_roles" this user has. Included ' + + 'for backward compat with `sdc-user get --membership ...` from ' + + 'node-smartdc.', + hidden: true + } +]; +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' + /* END JSSTYLED */ +); + +module.exports = do_user; diff --git a/lib/do_rbac/do_users.js b/lib/do_rbac/do_users.js new file mode 100644 index 0000000..cb6e74e --- /dev/null +++ b/lib/do_rbac/do_users.js @@ -0,0 +1,112 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright 2015 Joyent, Inc. + * + * `triton rbac users ...` + */ + +var tabula = require('tabula'); + +var common = require('../common'); +var errors = require('../errors'); + + + +// columns default without -o +var columnsDefault = 'shortid,login,email,name,cdate'; + +// columns default with -l +var columnsDefaultLong = 'id,login,email,firstName,lastName,created'; + +// sort default with -s +var sortDefault = 'login'; + + +function do_users(subcmd, opts, args, cb) { + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length !== 0) { + cb(new errors.UsageError('invalid args: ' + args)); + return; + } + + var columns = columnsDefault; + if (opts.o) { + columns = opts.o; + } else if (opts.long) { + columns = columnsDefaultLong; + } + columns = columns.split(','); + + var sort = opts.s.split(','); + + this.top.tritonapi.cloudapi.listUsers(function (err, users) { + if (err) { + cb(err); + return; + } + + if (opts.json) { + common.jsonStream(users); + } else { + // Add some convenience fields + for (var i = 0; i < users.length; i++) { + var user = users[i]; + user.shortid = user.id.split('-', 1)[0]; + user.name = ((user.firstName || '') + ' ' + + (user.lastName || '')).trim() || undefined; + if (user.created) { + user.cdate = user.created.slice(0, 10); // Just the date. + } + if (user.updated) { + user.udate = user.updated.slice(0, 10); // Just the date. + } + } + + tabula(users, { + skipHeader: opts.H, + columns: columns, + sort: sort + }); + } + cb(); + }); +} + +do_users.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + } +].concat(common.getCliTableOptions({ + includeLong: true, + sortDefault: sortDefault +})); + +do_users.help = ( + /* BEGIN JSSTYLED */ + 'List RBAC users.\n' + + '\n' + + 'Usage:\n' + + ' {{name}} users []\n' + + '\n' + + 'Fields (most are self explanatory, the client adds some for convenience):\n' + + ' shortid A short ID prefix.\n' + + ' name "firstName lastName"\n' + + ' cdate Just the date portion of "created"\n' + + ' udate Just the date portion of "updated"\n' + + '\n' + + '{{options}}' + /* END JSSTYLED */ +); + + + +module.exports = do_users; diff --git a/lib/do_rbac/index.js b/lib/do_rbac/index.js new file mode 100644 index 0000000..31b4d79 --- /dev/null +++ b/lib/do_rbac/index.js @@ -0,0 +1,47 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright 2015 Joyent, Inc. + * + * `triton rbac ...` + */ + +var Cmdln = require('cmdln').Cmdln; +var util = require('util'); + + + +// ---- CLI class + +function RbacCLI(top) { + this.top = top; + Cmdln.call(this, { + name: top.name + ' rbac', + /* BEGIN JSSTYLED */ + desc: 'Role-based Access Control (RBAC) commands. *Experimental.*\n' + + '\n' + + 'See for a general start.', + /* END JSSTYLED */ + helpOpts: { + minHelpCol: 24 /* line up with option help */ + } + }); +} +util.inherits(RbacCLI, Cmdln); + +RbacCLI.hidden = true; + +RbacCLI.prototype.init = function init(opts, args, cb) { + this.log = this.top.log; + Cmdln.prototype.init.apply(this, arguments); +}; + + +RbacCLI.prototype.do_users = require('./do_users'); +RbacCLI.prototype.do_user = require('./do_user'); + +module.exports = RbacCLI; diff --git a/lib/tritonapi.js b/lib/tritonapi.js index 1d153cb..62741de 100644 --- a/lib/tritonapi.js +++ b/lib/tritonapi.js @@ -10,7 +10,6 @@ * Core TritonApi client driver class. */ -var p = console.log; var assert = require('assert-plus'); var auth = require('smartdc-auth'); var EventEmitter = require('events').EventEmitter; @@ -91,6 +90,7 @@ TritonApi.prototype._cloudapiFromProfile = assert.string(profile.account, 'profile.account'); assert.string(profile.keyId, 'profile.keyId'); assert.string(profile.url, 'profile.url'); + assert.optionalString(profile.user, 'profile.user'); assert.optionalString(profile.privKey, 'profile.privKey'); assert.optionalBool(profile.insecure, 'profile.insecure'); var rejectUnauthorized = (profile.insecure === undefined @@ -100,18 +100,21 @@ TritonApi.prototype._cloudapiFromProfile = if (profile.privKey) { sign = auth.privateKeySigner({ user: profile.account, + subuser: profile.user, keyId: profile.keyId, key: profile.privKey }); } else { sign = auth.cliSigner({ keyId: profile.keyId, - user: profile.account + user: profile.account, + subuser: profile.user }); } var client = cloudapi.createClient({ url: profile.url, - user: profile.account, + account: profile.account, + user: profile.user, version: '*', rejectUnauthorized: rejectUnauthorized, sign: sign, @@ -585,6 +588,133 @@ TritonApi.prototype.getInstance = function getInstance(name, cb) { }; + +/** + * Get an RBAC user by ID, login, or short ID, in that order. + * + * @param {Object} opts + * - id {UUID|String} The user ID (a UUID), login or short id. + * - roles {Boolean} Optional. Whether to includes roles of which this + * user is a member. Default false. + * @param {Function} callback of the form `function (err, user)` + */ +TritonApi.prototype.getUser = function getUser(opts, cb) { + var self = this; + assert.object(opts, 'opts'); + assert.string(opts.id, 'opts.id'); + assert.optionalBool(opts.roles, 'opts.roles'); + assert.func(cb, 'cb'); + + /* + * CloudAPI GetUser supports a UUID or login, so we try that first. + * If that is a 404 and `opts.id` a valid shortid, then try to lookup + * via `listUsers`. + */ + var context = {}; + vasync.pipeline({arg: context, funcs: [ + function tryGetUser(ctx, next) { + var getOpts = { + id: opts.id, + membership: opts.roles + }; + self.cloudapi.getUser(getOpts, function (err, user) { + if (err) { + if (err.restCode === 'ResourceNotFound') { + ctx.notFoundErr = err; + next(); + } else { + next(err); + } + } else { + ctx.user = user; + next(); + } + }); + }, + + function tryShortId(ctx, next) { + if (ctx.user) { + next(); + return; + } + var shortId = common.normShortId(opts.id); + if (!shortId) { + next(); + return; + } + + self.cloudapi.listUsers(function (err, users) { + if (err) { + next(err); + return; + } + + var shortIdMatches = []; + for (var i = 0; i < users.length; i++) { + var user = users[i]; + // TODO: use this test in other shortId matching + if (user.id.slice(0, shortId.length) === shortId) { + shortIdMatches.push(user); + } + } + + if (shortIdMatches.length === 1) { + ctx.user = shortIdMatches[0]; + next(); + } else if (shortIdMatches.length === 0) { + next(new errors.ResourceNotFoundError(format( + 'user with login or id matching "%s" was not found', + opts.id))); + } else { + next(new errors.ResourceNotFoundError( + format('user with login "%s" was not found ' + + 'and "%s" is an ambiguous short id', opts.id))); + } + }); + }, + + /* + * If we found the user via `listUsers` and `opts.roles` was requested + * then we need to re-getUser. + */ + function reGetUserIfNecessary(ctx, next) { + if (!ctx.user) { + // We must have gotten the `notFoundErr` above. + next(new errors.ResourceNotFoundError(ctx.notFoundErr, format( + 'user with login or id %s was not found', opts.id))); + return; + } else if (!opts.roles || ctx.user.roles) { + next(); + return; + } + + var getOpts = { + id: ctx.user.id, + membership: opts.roles + }; + self.cloudapi.getUser(getOpts, function (err, user) { + if (err) { + if (err.restCode === 'ResourceNotFound') { + next(new errors.ResourceNotFoundError(err, format( + 'user with id %s was not found', opts.id))); + } else { + next(err); + } + } else { + ctx.user = user; + next(); + } + }); + } + + ]}, function (err) { + cb(err, context.user); + }); +}; + + + + //---- exports module.exports.createClient = function (options) { diff --git a/package.json b/package.json index 5aa4d0f..8399a90 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "triton", "description": "Joyent Triton CLI and client (https://www.joyent.com/triton)", - "version": "2.1.5", + "version": "3.0.0", "author": "Joyent (joyent.com)", "dependencies": { "assert-plus": "0.1.5",