diff --git a/CHANGES.md b/CHANGES.md index 247ef6f..fe5cd68 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -18,10 +18,13 @@ - `triton rbac policy ...` to show, create, edit and delete policies. - `triton rbac keys` to list all RBAC user SSH keys. - `triton rbac key ...` to show, create, edit and delete user keys. - - `triton rbac info` will dump a summary of the full current RBAC - configuration. This command is still in development. - `triton rbac {instance,image,network,package,}role-tags ...` to list and manage role tags on each of those resources. + - `triton rbac info` will dump a summary of the full current RBAC + state. This command is still in development. + - `triton rbac apply` will synchronize a local RBAC config (by default it + looks for "./rbac.json") to live RBAC state. Current the RBAC config + file format is undocumented. See "examples/rbac-\*" for examples. - #55 Update of smartdc-auth/sshpk deps, removal of duplicated code for composing Authorization headers diff --git a/examples/rbac-simple/README.md b/examples/rbac-simple/README.md new file mode 100644 index 0000000..04ed5d9 --- /dev/null +++ b/examples/rbac-simple/README.md @@ -0,0 +1,27 @@ +*Caveat*: All `triton rbac ...` support is experimental. + +This directly holds a super simple example Triton RBAC Profile for a mythical +"Simple Corp.", with `triton` CLI examples showing how to use it for RBAC. + +Our Simple corporation will create an "rbactestsimple" Triton account and +use RBAC to manage its users, roles, etc. It has two users: + +- emma: Should have full access, to everything. +- bert: Should only have read access, again to everything. + +We want an RBAC config that allows appropriate access for all the employees +and tooling. Roughly we'll break that into roles as follows: + +- Role `admin`. Complete access to the API. Only used by "emma" when, e.g., + updating RBAC configuration itself. +- Role `ops`. Full access, except to RBAC configuration updates. +- Role `read`. Read-only access to compute resources. + +See "rbac.json" where we encode all this. + +The `triton rbac apply` command can work with a JSON config file (and +optionally separate user public ssh key files) to create and maintain a +Triton RBAC configuration. In our example this will be: + + triton rbac apply # defaults to looking at "./rbac.json" + diff --git a/examples/rbac-simple/rbac-user-keys/emma.pub b/examples/rbac-simple/rbac-user-keys/emma.pub new file mode 100644 index 0000000..0b613b8 --- /dev/null +++ b/examples/rbac-simple/rbac-user-keys/emma.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDeDi6WjD2EOe+N8tkJXsDmjMii4qrJfEm4TX56Yezrjxr5QBSoPRcahvX+n352LYbkid+23lNrqGdBDlhracbFHoduWUetStRGoQWk52c7Piu13ETqWwoqxSlV9L9Ajb9h78AhBkTdtMlGp/0s6yREsjirUYyi/sGfHaj4WJU2Iqh3Ig1VsOcSUmm5npYC19UF+5hbEYTDEidkfRpjdDXA5EGTTce9ytsELOvFQaI/UuwXIS8Xd+1Eg/a+T3eFkx8VN8lAu067JfJNh8baaJcf5Bim3VcaTVDaYshwHJ0OKRxwvV/PqHLrEEbUA2zdN1oUY+Wyj/DQYdijCO8xGT41cZiJDINxfy0nJLEQxpfsnJw1QhRujezyekumdveQf0SoXZtUiBnWF0E/tMgMdQ8j6fFLUGgu08hIHZU0keIYUV1bAvJJhxJM58wemjmCch0BdZd0bBbujxeicFO+N1comeZjKhpLHQHwrQhRLu+oZgI41g4zNk+8lUuy6yI3pYdD+ThAsKrV34KpPCSIVA9KbLb4uIxZzpc41uh4AT6Xu1raXeqRn1ERW3XD3L2dmNaO444iEPgNdEzG7TG6Is172GOFZT75SkkdCV7UlsPFD0O7UXoDD9rdGjoi+LmIyX4MrTLXctOalNyXV9g+RiPETOfCz4dbH6cZjq/V6NdMBw== emma diff --git a/examples/rbac-simple/rbac.json b/examples/rbac-simple/rbac.json new file mode 100644 index 0000000..6d9ccd0 --- /dev/null +++ b/examples/rbac-simple/rbac.json @@ -0,0 +1,43 @@ +{ + "users": [ + { "login": "emma", "email": "emma@simple.example.com", "keys": "rbac-user-keys" }, + { "login": "bert", "email": "bert@simple.example.com" } + ], + "roles": [ + { + "name": "admin", + "default_members": [], + "members": ["emma"], + "policies": ["policy-admin"] + }, + { + "name": "ops", + "default_members": ["emma"], + "members": ["emma"], + "policies": ["policy-full"] + }, + { + "name": "read", + "default_members": ["bert", "emma"], + "members": ["bert", "emma"], + "policies": ["policy-readonly"] + } + ], + "policies": [ + { + "name": "policy-admin", + "description": "full access", + "rules": ["CAN *"] + }, + { + "name": "policy-full", + "description": "full access, except rbac", + "rules": ["CAN compute:*"] + }, + { + "name": "policy-readonly", + "description": "read-only access", + "rules": ["CAN compute:Get*"] + } + ] +} diff --git a/lib/cloudapi2.js b/lib/cloudapi2.js index c070523..e9b16d7 100644 --- a/lib/cloudapi2.js +++ b/lib/cloudapi2.js @@ -760,17 +760,19 @@ CloudApi.prototype.createUser = function createUser(opts, cb) { * * * @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. + * - {String} id (required) for your user. This can be either the user + * login, or the user id (UUID) + * - {String} login (optional) + * - {String} email (optional) + * - {String} companyName (optional) + * - {String} firstName (optional) + * - {String} lastName (optional) + * - {String} address (optional) + * - {String} postalCode (optional) + * - {String} city (optional) + * - {String} state (optional) + * - {String} country (optional) + * - {String} phone (optional) * @param {Function} cb of the form `function (err, user, res)` */ CloudApi.prototype.updateUser = function updateUser(opts, cb) { @@ -780,6 +782,7 @@ CloudApi.prototype.updateUser = function updateUser(opts, cb) { assert.func(cb, 'cb'); var update = { + login: opts.login, email: opts.email, companyName: opts.companyName, firstName: opts.firstName, @@ -837,7 +840,7 @@ CloudApi.prototype.listUserKeys = function listUserKeys(opts, cb) { assert.func(cb, 'cb'); var endpoint = format('/%s/users/%s/keys', this.account, opts.userId); - this._passThrough(endpoint, opts, cb); + this._passThrough(endpoint, {}, cb); }; diff --git a/lib/common.js b/lib/common.js index 2ff2637..eb8dfce 100644 --- a/lib/common.js +++ b/lib/common.js @@ -10,6 +10,7 @@ var assert = require('assert-plus'); var child_process = require('child_process'); +var crypto = require('crypto'); var fs = require('fs'); var os = require('os'); var path = require('path'); @@ -502,7 +503,7 @@ function promptYesNo(opts_, cb) { case '\n': case '\r': case '\u0004': - // They've finished typing their answer + // EOT. They've finished typing their answer postInput(); var answer = input.toLowerCase(); if (answer === '' && opts.default) { @@ -517,12 +518,23 @@ function promptYesNo(opts_, cb) { return; } break; - case '\u0003': - // Ctrl C + case '\u0003': // Ctrl C postInput(); finish(false); break; + case '\u007f': // DEL + input = input.slice(0, -1); + stdout.clearLine(); + stdout.cursorTo(0); + stdout.write(opts.msg); + stdout.write(input); + break; default: + // Rule out special ASCII chars. + var code = ch.charCodeAt(0); + if (0 <= code && code <= 31) { + break; + } // More plaintext characters stdout.write(ch); input += ch; @@ -717,6 +729,30 @@ function chomp(s) { } +/* + * Generate a random password of the specified length (default 20 chars) + * using ASCII printable, non-space chars: ASCII 33-126 (inclusive). + * + * No idea if this is crypto-sound. Doubt it. + */ +function generatePassword(opts) { + assert.optionalObject(opts, 'opts'); + opts = opts || {}; + assert.optionalNumber(opts.len, 'opts.len'); + + var buf = crypto.randomBytes(opts.len || 20); + var min = 33; + var max = 126; + var chars = []; + for (var i = 0; i < buf.length; i++) { + var num = Math.round(((buf[i] / 0xff) * (max - min)) + min); + chars.push(String.fromCharCode(num)); + } + + return chars.join(''); +} + + //---- exports @@ -743,6 +779,7 @@ module.exports = { editInEditor: editInEditor, ansiStylize: ansiStylize, indent: indent, - chomp: chomp + chomp: chomp, + generatePassword: generatePassword }; // vim: set softtabstop=4 shiftwidth=4: diff --git a/lib/do_rbac/do_apply.js b/lib/do_rbac/do_apply.js new file mode 100644 index 0000000..cb8384e --- /dev/null +++ b/lib/do_rbac/do_apply.js @@ -0,0 +1,137 @@ +/* + * 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 apply ...` + */ + +var assert = require('assert-plus'); +var format = require('util').format; +var vasync = require('vasync'); + +var common = require('../common'); +var errors = require('../errors'); +var rbac = require('../rbac'); + +var ansiStylize = common.ansiStylize; + + +function do_apply(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 context = { + log: this.log, + tritonapi: this.top.tritonapi, + cloudapi: this.top.tritonapi.cloudapi, + rbacConfigPath: opts.file || './rbac.json', + rbacDryRun: opts.dry_run + }; + vasync.pipeline({arg: context, funcs: [ + rbac.loadRbacConfig, + rbac.loadRbacState, + rbac.createRbacUpdatePlan, + function confirmApply(ctx, next) { + if (opts.yes || ctx.rbacUpdatePlan.length === 0) { + next(); + return; + } + ctx.log.info({rbacUpdatePlan: ctx.rbacUpdatePlan}, + 'rbacUpdatePlan'); + var p = console.log; + p(''); + p('This will make the following RBAC config changes:'); + ctx.rbacUpdatePlan.forEach(function (c) { + // TODO: consider having this summarize the changes, e.g.: + // Add 5 users (bob, linda, ...) + // Remove all 5 roles (...) + var extra = ''; + if (c.action === 'update') { + extra = format(' (%s)', + Object.keys(c.diff).map(function (field) { + return c.diff[field] + ' ' + field; + }).join(', ')); + } + p(' %s %s %s%s', + {create: 'Create', 'delete': 'Delete', + update: 'Update'}[c.action], + c.desc || c.type, + c.id, + extra); + }); + p(''); + var msg = format('Would you like to continue%s? [y/N] ', + opts.dry_run ? ' (dry-run)' : ''); + common.promptYesNo({msg: msg, default: 'n'}, function (answer) { + if (answer !== 'y') { + p('Aborting update'); + return cb(); + } + p(''); + next(); + }); + }, + rbac.executeRbacUpdatePlan + ]}, function (err) { + cb(err); + }); +} + +do_apply.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['dry-run', 'n'], + type: 'bool', + help: 'Go through the motions without applying changes.' + }, + { + names: ['yes', 'y'], + type: 'bool', + help: 'Answer "yes" to confirmation.' + }, + { + names: ['file', 'f'], + type: 'string', + helpArg: 'FILE', + help: 'RBAC config JSON file.' + } +]; + +do_apply.help = [ + /* BEGIN JSSTYLED */ + 'Apply an RBAC configuration.', + '', + 'Usage:', + ' {{name}} apply []', + '', + '{{options}}', + 'If "--file FILE" is not specified, this defaults to using "./rbac.json".', + 'The RBAC configuration is loaded from FILE and compared to the live', + 'RBAC state (see `triton rbac info`). It then calculates necessary updates,', + 'confirms, and applies them.', + '', + 'Warning: Currently, RBAC state updates can take a few seconds to appear', + 'as they are replicated across data centers. This can result in unexpected', + 'no-op updates with consecutive quick re-runs of this command.', + '', + 'TODO: Document the rbac.json configuration format.' + /* END JSSTYLED */ +].join('\n'); + + + +module.exports = do_apply; diff --git a/lib/do_rbac/do_info.js b/lib/do_rbac/do_info.js index 0f8a8b9..3221793 100644 --- a/lib/do_rbac/do_info.js +++ b/lib/do_rbac/do_info.js @@ -58,41 +58,12 @@ var vasync = require('vasync'); var common = require('../common'); var errors = require('../errors'); +var rbac = require('../rbac'); var ansiStylize = common.ansiStylize; -/* - * Gather RBAC users, policies, roles and add those to the given `ctx` object. - */ -function gatherRbacBasicInfo(ctx, cb) { - assert.object(ctx.cloudapi, 'ctx.cloudapi'); - assert.func(cb, 'cb'); - - vasync.parallel({funcs: [ - function listUsers(next) { - ctx.cloudapi.listUsers(function (err, users) { - ctx.users = users; - next(err); - }); - }, - function listPolicies(next) { - ctx.cloudapi.listPolicies(function (err, policies) { - ctx.policies = policies; - next(err); - }); - }, - function listRoles(next) { - ctx.cloudapi.listRoles(function (err, roles) { - ctx.roles = roles; - next(err); - }); - } - ]}, cb); -} - - function do_info(subcmd, opts, args, cb) { if (opts.help) { this.do_help('help', {}, [subcmd], cb); @@ -104,60 +75,23 @@ function do_info(subcmd, opts, args, cb) { var log = this.log; var context = { + log: this.log, tritonapi: this.top.tritonapi, - cloudapi: this.top.tritonapi.cloudapi + cloudapi: this.top.tritonapi.cloudapi, + rbacStateAll: opts.all }; vasync.pipeline({arg: context, funcs: [ - gatherRbacBasicInfo, - function gatherUserKeys(ctx, next) { - if (!opts.all) { - next(); - return; - } - // XXX Q! or concurrency forEachParallel - vasync.forEachParallel({ - inputs: ctx.users, - func: function oneUser(user, nextUser) { - ctx.cloudapi.listUserKeys({userId: user.id}, - function (err, userKeys) { - user.keys = userKeys; - nextUser(err); - }); - } - }, next); - }, - function fillInUserRoles(ctx, next) { - var i; - var userFromLogin = {}; - for (i = 0; i < ctx.users.length; i++) { - var user = ctx.users[i]; - user.default_roles = []; - user.roles = []; - userFromLogin[user.login] = user; - } - for (i = 0; i < ctx.roles.length; i++) { - var role = ctx.roles[i]; - role.default_members.forEach(function (login) { - userFromLogin[login].default_roles.push(role.name); - }); - role.members.forEach(function (login) { - userFromLogin[login].roles.push(role.name); - }); - } - next(); - }, + + rbac.loadRbacState, + function printInfo(ctx, next) { var i; - log.trace({ - users: ctx.users, - policies: ctx.policies, - roles: ctx.roles - }, 'rbac info data'); + log.trace({rbacState: ctx.rbacState}, 'rbacState'); - console.log('users (%d):', ctx.users.length); - tabula.sortArrayOfObjects(ctx.users, ['name']); - for (i = 0; i < ctx.users.length; i++) { - var user = ctx.users[i]; + console.log('users (%d):', ctx.rbacState.users.length); + tabula.sortArrayOfObjects(ctx.rbacState.users, ['name']); + for (i = 0; i < ctx.rbacState.users.length; i++) { + var user = ctx.rbacState.users[i]; var userExtra = []; if (user.firstName || user.lastName) { @@ -197,10 +131,10 @@ function do_info(subcmd, opts, args, cb) { userExtra, roleInfo); } - console.log('roles (%d):', ctx.roles.length); - tabula.sortArrayOfObjects(ctx.roles, ['name']); - for (i = 0; i < ctx.roles.length; i++) { - var role = ctx.roles[i]; + console.log('roles (%d):', ctx.rbacState.roles.length); + tabula.sortArrayOfObjects(ctx.rbacState.roles, ['name']); + for (i = 0; i < ctx.rbacState.roles.length; i++) { + var role = ctx.rbacState.roles[i]; var policyInfo; if (role.policies.length === 1) { @@ -214,10 +148,10 @@ function do_info(subcmd, opts, args, cb) { policyInfo); } - console.log('policies (%d):', ctx.policies.length); - tabula.sortArrayOfObjects(ctx.policies, ['name']); - for (i = 0; i < ctx.policies.length; i++) { - var policy = ctx.policies[i]; + console.log('policies (%d):', ctx.rbacState.policies.length); + tabula.sortArrayOfObjects(ctx.rbacState.policies, ['name']); + for (i = 0; i < ctx.rbacState.policies.length; i++) { + var policy = ctx.rbacState.policies[i]; var noRules = ''; if (policy.rules.length === 0) { noRules = ' ' + ansiStylize('no rules', 'red'); @@ -258,7 +192,7 @@ do_info.options = [ do_info.help = ( /* BEGIN JSSTYLED */ - 'Print an account RBAC summary.\n' + + 'Show current RBAC state.\n' + '\n' + 'Usage:\n' + ' {{name}} info []\n' + diff --git a/lib/do_rbac/index.js b/lib/do_rbac/index.js index bc66e10..e37769c 100644 --- a/lib/do_rbac/index.js +++ b/lib/do_rbac/index.js @@ -22,9 +22,11 @@ function RbacCLI(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.', + desc: [ + 'Role-based Access Control (RBAC) commands.', + 'See for a general start.', + '**Warning: `triton rbac ...` is experimental, not well tested and in flux.**' + ].join('\n'), /* END JSSTYLED */ helpOpts: { minHelpCol: 24 /* line up with option help */ @@ -32,6 +34,7 @@ function RbacCLI(top) { helpSubcmds: [ 'help', 'info', + 'apply', { group: 'RBAC Resources' }, 'users', 'user', @@ -59,6 +62,7 @@ RbacCLI.prototype.init = function init(opts, args, cb) { }; RbacCLI.prototype.do_info = require('./do_info'); +RbacCLI.prototype.do_apply = require('./do_apply'); RbacCLI.prototype.do_users = require('./do_users'); RbacCLI.prototype.do_user = require('./do_user'); diff --git a/lib/rbac.js b/lib/rbac.js new file mode 100644 index 0000000..7994b4b --- /dev/null +++ b/lib/rbac.js @@ -0,0 +1,758 @@ +/* + * 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. + * + * RBAC-related support. + */ + +var assert = require('assert-plus'); +var format = require('util').format; +var fs = require('fs'); +var path = require('path'); +var sshpk = require('sshpk'); +var vasync = require('vasync'); + +var common = require('./common'); +var errors = require('./errors'); + + +// ---- internal support stuff + +function _rbacStateBasics(ctx, cb) { + assert.object(ctx.cloudapi, 'ctx.cloudapi'); + assert.func(cb, 'cb'); + + vasync.parallel({funcs: [ + function listUsers(next) { + ctx.cloudapi.listUsers(function (err, users) { + ctx.rbacState.users = users; + next(err); + }); + }, + function listPolicies(next) { + ctx.cloudapi.listPolicies(function (err, policies) { + ctx.rbacState.policies = policies; + next(err); + }); + }, + function listRoles(next) { + ctx.cloudapi.listRoles(function (err, roles) { + ctx.rbacState.roles = roles; + next(err); + }); + } + ]}, cb); +} + + + +/** + * Take `have` and `want` arrays of things (objects) and return an array of + * "change" objects describing how to get from 'have' to 'want'. + * + * TODO: picture === 1000 words. Give an example. + * + * @param opts.type {String} The type of thing being compared. + * @param opts.desc {String} Optional. A short descriptive phrase for this + * thing. + * @param opts.idField {String} The field with the unique thing id. + * @param opts.have {Array} Array of things we have already. + * @param opts.want {Array} Array of things we want to get to. + * @param opts.crudChangesForCreate {Function} Optional. A function called: + * crudChangesForCreate(wantThing) + * to return zero or more change objects to add this thing. + * @param opts.crudChangesForDelete {Function} Optional. A function called: + * crudChangesForDelete(haveThing) + * to return zero or more change objects to delete this thing. + * @param opts.normThing {Function} Optional. A function called: + * var normalized = normThing(thing); + * to normalize a thing before comparing them with `crudChangesForUpdate`. + * @param opts.crudChangesForUpdate {Function} Optional. A function called: + * crudChangesForUpdate(haveThing, wantThing) + * to compare to things and return zero or more change objects (of + * action="update") for the update. If not specified, the default + * comparison is a field-by-field "deepEqual" comparison. + * @param opts.compareFields {Array} Optional. An alternative to specifying + * `opts.crudChangesForUpdate` is to specify an array of field names to + * consider. This will be used by the default crudChangesForUpdate. + */ +function crudChangesForThings(opts) { + assert.object(opts, 'opts'); + assert.string(opts.type, 'opts.type'); + assert.optionalString(opts.desc, 'opts.desc'); + assert.string(opts.idField, 'opts.idField'); + assert.arrayOfObject(opts.have, 'opts.have'); + assert.arrayOfObject(opts.want, 'opts.want'); + assert.optionalFunc(opts.crudChangesForCreate, 'opts.crudChangesForCreate'); + assert.optionalFunc(opts.crudChangesForDelete, 'opts.crudChangesForDelete'); + assert.optionalFunc(opts.normThing, 'opts.normThing'); + assert.optionalFunc(opts.crudChangesForUpdate, 'opts.crudChangesForUpdate'); + assert.optionalArrayOfString(opts.compareFields, 'opts.compareFields'); + + var idField = opts.idField; + + var differ = function (a, b) { + try { + assert.deepEqual(a, b); + } catch (err) { + return true; + } + return false; + }; + + var crudChangesForCreate = opts.crudChangesForCreate || + function defaultCrudChangesForCreate(wantThing_) { + return [ { + action: 'create', + type: opts.type, + desc: opts.desc, + id: wantThing_[idField], + wantThing: wantThing_ + } ]; + }; + + var crudChangesForDelete = opts.crudChangesForDelete || + function defaultCrudChangesForDelete(haveThing_) { + return [ { + action: 'delete', + type: opts.type, + desc: opts.desc, + id: haveThing_[idField], + haveThing: haveThing_ + } ]; + }; + + var crudChangesForUpdate = opts.crudChangesForUpdate || + function defaultUpdatesForThing(haveThing_, wantThing_) { + var diff = {}; + Object.keys(haveThing_).forEach(function (field) { + if (! wantThing_.hasOwnProperty(field)) { + diff[field] = 'delete'; + } else if (differ(haveThing_[field], wantThing_[field])) { + diff[field] = 'update'; + } + }); + Object.keys(wantThing_).forEach(function (field) { + if (! haveThing_.hasOwnProperty(field)) { + diff[field] = 'create'; + } + }); + if (opts.compareFields) { + var filteredDiff = {}; + opts.compareFields.forEach(function (field) { + if (diff.hasOwnProperty(field)) { + filteredDiff[field] = diff[field]; + } + }); + diff = filteredDiff; + } + if (Object.keys(diff).length === 0) { + return []; + } else { + return [ { + action: 'update', + type: opts.type, + desc: opts.desc, + id: wantThing_[idField], + diff: diff, + haveThing: haveThing_, + wantThing: wantThing_ + } ]; + } + }; + + var normThing = opts.normThing || + function defaultNormThing(thing) { + return thing; + }; + + var haveFromId = {}; + opts.have.forEach( + function (thing) { haveFromId[thing[idField]] = thing; }); + var wantFromId = {}; + opts.want.forEach( + function (thing) { wantFromId[thing[idField]] = thing; }); + + // Updates and creates. + var i, haveThing, id; + var changes = []; + for (i = 0; i < opts.want.length; ++i) { + var wantThing = opts.want[i]; + id = wantThing[idField]; + haveThing = haveFromId[id]; + if (haveThing) { + var updates = crudChangesForUpdate( + normThing(haveThing), normThing(wantThing)); + changes = changes.concat(updates); + } else { + changes = changes.concat(crudChangesForCreate(wantThing)); + } + } + + // Deletions. + for (i = 0; i < opts.have.length; ++i) { + haveThing = opts.have[i]; + id = haveThing[idField]; + if (! wantFromId[id]) { + changes = changes.concat(crudChangesForDelete(haveThing)); + } + } + + return changes; +} + + + +// ---- exported functions + +/* + * Load a Triton RBAC config file. + * TODO: link to docs on this. + * + * The result is written to `ctx.rbacConfig`. + * This calling style is used to facilitate using this in a `vasync.pipeline`. + * + * @param ctx {Object} The "context". + * @param cb {Function} `function (err)` + */ +function loadRbacConfig(ctx, cb) { + assert.object(ctx, 'ctx'); + assert.string(ctx.rbacConfigPath, 'ctx.rbacConfigPath'); + assert.func(cb, 'cb'); + + vasync.pipeline({funcs: [ + function readIt(_, next) { + fs.readFile(ctx.rbacConfigPath, function (err, data) { + if (err) { + next(err); + return; + } + try { + ctx.rbacConfig = JSON.parse(data); + } catch (jsonErr) { + throw new errors.TritonError(format( + 'Triton RBAC config file, %s, is not valid JSON: %s', + ctx.rbacConfigPath, jsonErr)); + } + next(); + }); + }, + + /* + * The RBAC config format allows keys to be in a separate file + * or dir specified with the `.keys` property. + */ + function loadUserKeys(_, next) { + vasync.forEachPipeline({ + inputs: ctx.rbacConfig.users || [], + func: function loadForOneUser(user, nextUser) { + if (!user.keys || typeof (user.keys) !== 'string') { + nextUser(); + return; + } + + var keysFile = user.keys; + try { + var stat = fs.statSync(keysFile); + } catch (statErr) { + throw new errors.TritonError(format( + 'User %s keys not found in "%s": %s', + user.login, keysFile, statErr)); + } + if (stat.isDirectory()) { + keysFile = path.join(user.keys, user.login + '.pub'); + try { + stat = fs.statSync(keysFile); + } catch (statErr) { + throw new errors.TritonError(format( + 'User %s keys not found in "%s": %s', + user.login, keysFile, statErr)); + } + } + if (!stat.isFile()) { + throw new errors.TritonError(format( + 'Expected "%s" to be a regular file', keysFile)); + } + + var data = fs.readFileSync(keysFile, 'utf8'); + var lines = data.split(/\r?\n/g); + user.keys = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (! line.trim()) { + continue; + } + try { + var key = sshpk.parseKey(line, 'ssh', keysFile); + } catch (keyErr) { + // XXX more err details, wrap with TritonError + nextUser(keyErr); + return; + } + user.keys.push({ + fingerprint: key.fingerprint('md5').toString(), + name: key.comment || undefined, + key: line + }); + } + nextUser(); + } + }, next); + } + + // XXX Add JSON schema validations + // XXX disallow password in the config file + // XXX cannot use a login in default_members/members that + // isn't in "users" + // function validateConfig(_, next) { + // } + ]}, function (err) { + cb(err); + }); +} + +/* + * Gather RBAC users, policies, roles and add those to the given `ctx` object. + * + * The result is written to `ctx.rbacState`. + * This calling style is used to facilitate using this in a `vasync.pipeline`. + * + * @param ctx {Object} The "context". + * - rbacStateAll {Boolean} Set to true to gather extra details like + * user keys. Default false. + * @param cb {Function} `function (err)` + */ +function loadRbacState(ctx, cb) { + assert.object(ctx.cloudapi, 'ctx.cloudapi'); + assert.object(ctx.log, 'ctx.log'); + + var rbacState = ctx.rbacState = {}; + + vasync.pipeline({arg: ctx, funcs: [ + _rbacStateBasics, + function gatherUserKeys(_, next) { + if (ctx.rbacStateAll) { + next(); + return; + } + // XXX Q! or concurrency forEachParallel + // TODO: Optimization: could avoid getting keys for users that are + // going to be deleted, for the `triton rbac apply` use case. + vasync.forEachParallel({ + inputs: rbacState.users, + func: function oneUser(user, nextUser) { + ctx.cloudapi.listUserKeys({userId: user.id}, + function (err, userKeys) { + user.keys = userKeys; + nextUser(err); + }); + } + }, next); + }, + function fillInUserRoles(_, next) { + var i; + var userFromLogin = {}; + for (i = 0; i < rbacState.users.length; i++) { + var user = rbacState.users[i]; + user.default_roles = []; + user.roles = []; + userFromLogin[user.login] = user; + } + for (i = 0; i < rbacState.roles.length; i++) { + var role = rbacState.roles[i]; + role.default_members.forEach(function (login) { + userFromLogin[login].default_roles.push(role.name); + }); + role.members.forEach(function (login) { + userFromLogin[login].roles.push(role.name); + }); + } + next(); + } + ]}, function (err) { + cb(err); + }); +} + + +/* + * For each of users (must be before roles), policies (must be before + * roles), and roles we calculate updates, creates, and deletions. + */ +function createRbacUpdatePlan(ctx, cb) { + assert.object(ctx.rbacConfig, 'ctx.rbacConfig'); + assert.object(ctx.rbacState, 'ctx.rbacState'); + + // XXX want option to exclude user list from the rbac config? + // XXX handle user key updates + // XXX guard for a full delete of all objects of any type + // XXX guard for removal of a role that resources tagged with that + // role could leave orphaned role tags + // XXX can *rename* of policies and roles be supported? + // would need 'id' to be more complex + + var sections = [ + { + type: 'user', + desc: 'user', + idField: 'login', + have: ctx.rbacState.users || [], + want: ctx.rbacConfig.users || [], + /* + * *User* update is loose: we only compare fields specified in + * the RBAC config file. Also, user updates include updates to keys. + */ + crudChangesForUpdate: function userUpdates(haveUser, wantUser) { + var updates = []; + + var fields = []; + Object.keys(wantUser).forEach(function (field) { + if (field === 'keys') { + return; // keys handled below + } + if (haveUser[field] !== wantUser[field]) { + fields.push(field); + } + }); + if (fields.length) { + updates.push({ + action: 'update', + type: 'user', + id: haveUser.login, + fields: fields, + haveThing: haveUser, + wantThing: wantUser + }); + } + + // Note: If we get fingerprint formats other than 'md5', then + // id comparison will have to switch to + // `sshpk.parseKey().matches`. + var keyChanges = crudChangesForThings({ + type: 'key', + desc: format('user %s key', haveUser.login), + idField: 'fingerprint', + have: haveUser.keys || [], + want: wantUser.keys || [] + }); + keyChanges.forEach(function (c) { + c.user = haveUser.login; + updates.push(c); + }); + + return updates; + }, + crudChangesForCreate: function userCreates(wantUser) { + var creates = [ { + action: 'create', + type: 'user', + id: wantUser.login, + wantThing: wantUser + } ]; + + // Add any keys. + (wantUser.keys || []).forEach(function (key) { + creates.push({ + action: 'create', + type: 'key', + desc: format('user %s key', wantUser.login), + user: wantUser.login, + id: key.fingerprint, + wantThing: key + }); + }); + + return creates; + }, + + crudChangesForDelete: function userDeletes(haveUser) { + var deletes = []; + + (haveUser.keys || []).forEach(function (key) { + deletes.push({ + action: 'delete', + type: 'key', + desc: format('user %s key', haveUser.login), + user: haveUser.login, + id: key.fingerprint, + haveThing: key + }); + }); + + deletes.push({ + action: 'delete', + type: 'user', + id: haveUser.login, + haveThing: haveUser + }); + + return deletes; + } + }, + { + type: 'policy', + idField: 'name', + have: ctx.rbacState.policies || [], + want: ctx.rbacConfig.policies || [], + compareFields: ['description', 'rules'], + normThing: function normPolicy(policy) { + policy.rules.sort(); + return policy; + } + }, + { + type: 'role', + idField: 'name', + have: ctx.rbacState.roles || [], + want: ctx.rbacConfig.roles || [], + compareFields: ['members', 'default_members', 'policies'], + normThing: function normRole(role) { + role.members.sort(); + role.default_members.sort(); + role.policies.sort(); + return role; + } + } + ]; + + + var changes = []; + sections.forEach(function (section) { + var someChanges = crudChangesForThings(section); + changes = changes.concat(someChanges); + }); + + ctx.rbacUpdatePlan = changes; + cb(); +} + +function executeRbacUpdatePlan(ctx, cb) { + assert.object(ctx.log, 'ctx.log'); + assert.arrayOfObject(ctx.rbacUpdatePlan, 'ctx.rbacUpdatePlan'); + assert.object(ctx.cloudapi, 'ctx.cloudapi'); + assert.optionalBool(ctx.rbacDryRun, 'ctx.rbacDryRun'); + + // TODO: ctx.progress instead of console.log + + vasync.forEachPipeline({ + inputs: ctx.rbacUpdatePlan, + func: function executeOneChange(c, next) { + ctx.log.info({change: c, dryRun: ctx.rbacDryRun}, + 'execute rbac update change'); + var extra, delOpts, updateOpts; + + if (ctx.rbacDryRun) { + console.log('[dry-run] %s %s %s', c.action, + c.desc || c.type, c.id); + next(); + return; + } + + switch (c.action + '-' + c.type) { + case 'create-user': + // Generate (throw away) password, if necessary. + if (! c.wantThing.hasOwnProperty('password')) { + c.wantThing.password = common.generatePassword(); + } + ctx.cloudapi.createUser(c.wantThing, function (err, user) { + if (err) { + next(err); + return; + } + console.log('Created user %s', c.wantThing.login); + next(); + }); + break; + case 'update-user': + updateOpts = {id: c.wantThing.login}; + extra = []; + Object.keys(c.diff).forEach(function (field) { + updateOpts[field] = c.wantThing[field]; + extra.push(format('%s=%s', field, c.wantThing[field])); + }); + ctx.cloudapi.updateUser(updateOpts, function (err, user) { + if (err) { + next(err); + return; + } + console.log('Updated user %s: %s', c.wantThing.login, + extra.join(', ')); + next(); + }); + break; + case 'delete-user': + delOpts = {id: c.haveThing.login}; + ctx.cloudapi.deleteUser(delOpts, function (err) { + if (err) { + next(err); + return; + } + console.log('Deleted user %s', c.haveThing.login); + next(); + }); + break; + + case 'create-key': + ctx.cloudapi.createUserKey({ + userId: c.user, + key: c.wantThing.key, + name: c.wantThing.name + }, function (err, key) { + if (err) { + next(err); + return; + } + console.log('Created user %s key %s%s', c.user, + key.fingerprint, + key.name ? format(' (%s)', key.name) : ''); + next(); + }); + break; + case 'update-key': + vasync.pipeline({funcs: [ + function delKey(_, next2) { + ctx.cloudapi.deleteUserKey({ + userId: c.user, + fingerprint: c.haveThing.fingerprint + }, next2); + }, + function createKey(_, next2) { + ctx.cloudapi.createUserKey({ + userId: c.user, + key: c.wantThing.key, + name: c.wantThing.name + }, next2); + }, + function noteIt(_, next2) { + extra = Object.keys(c.diff).map(function (field) { + if (field === 'key') { + return 'key=...'; + } + return format('%s=%s', field, c.wantThing[field]); + }); + console.log('Updated user %s key %s: %s', + c.user, c.wantThing.fingerprint, extra.join(', ')); + next2(); + } + ]}, next); + break; + case 'delete-key': + ctx.cloudapi.deleteUserKey({ + userId: c.user, + fingerprint: c.haveThing.fingerprint + }, function (err) { + if (err) { + next(err); + return; + } + console.log('Deleted user %s key %s', c.user, + c.haveThing.fingerprint); + next(); + }); + break; + + case 'create-policy': + ctx.cloudapi.createPolicy(c.wantThing, function (err, poli) { + if (err) { + next(err); + return; + } + console.log('Created policy %s (%s rule%s)', poli.name, + poli.rules.length, poli.rules.length === 1 ? '' : 's'); + next(); + }); + break; + case 'update-policy': + updateOpts = {id: c.wantThing.name}; + extra = []; + Object.keys(c.diff).forEach(function (field) { + updateOpts[field] = c.wantThing[field]; + // XXX This is poor for large rules update. + extra.push(format('%s=%s', field, c.wantThing[field])); + }); + ctx.cloudapi.updatePolicy(updateOpts, function (err, poli) { + if (err) { + next(err); + return; + } + console.log('Updated policy %s: %s', poli.name, + extra.join(', ')); + next(); + }); + break; + case 'delete-policy': + delOpts = {id: c.haveThing.id}; // DeletePolicy requires `id`. + ctx.cloudapi.deletePolicy(delOpts, function (err) { + if (err) { + next(err); + return; + } + console.log('Deleted policy %s', c.haveThing.name); + next(); + }); + break; + + case 'create-role': + ctx.cloudapi.createRole(c.wantThing, function (err, role) { + if (err) { + next(err); + return; + } + console.log('Created role %s (%s members%s)', + role.name, role.members.length, + role.members.length === 1 ? '' : 's'); + next(); + }); + break; + case 'update-role': + updateOpts = {id: c.wantThing.name}; + extra = []; + Object.keys(c.diff).forEach(function (field) { + updateOpts[field] = c.wantThing[field]; + extra.push(format('%s=%s', field, c.wantThing[field])); + }); + ctx.cloudapi.updateRole(updateOpts, function (err, role) { + if (err) { + next(err); + return; + } + console.log('Updated role %s: %s', role.name, + extra.join(', ')); + next(); + }); + break; + case 'delete-role': + delOpts = {id: c.haveThing.id}; + ctx.cloudapi.deleteRole(delOpts, function (err) { + if (err) { + next(err); + return; + } + console.log('Deleted role %s', c.haveThing.name); + next(); + }); + break; + + default: + throw new Error(format( + 'unknown action-type: %s-%s', c.action, c.type)); + } + } + + }, function (err) { + cb(err); + }); +} + + +module.exports = { + loadRbacConfig: loadRbacConfig, + loadRbacState: loadRbacState, + createRbacUpdatePlan: createRbacUpdatePlan, + executeRbacUpdatePlan: executeRbacUpdatePlan +};