From 82443e2d67f84bee0e85674ebd6577d37e3d7323 Mon Sep 17 00:00:00 2001 From: Trent Mick Date: Mon, 23 Nov 2015 16:57:58 -0800 Subject: [PATCH] joyent/node-triton#54 'triton rbac apply --dev-create-keys-and-profiles' --- CHANGES.md | 3 + examples/rbac-simple/rbac-user-keys/emma.pub | 1 - lib/common.js | 56 ++++++++- lib/do_rbac/do_apply.js | 113 ++++++++++++++++- lib/rbac.js | 125 ++++++++++++++++++- package.json | 1 + 6 files changed, 288 insertions(+), 11 deletions(-) delete mode 100644 examples/rbac-simple/rbac-user-keys/emma.pub diff --git a/CHANGES.md b/CHANGES.md index f8a0522..77d6b06 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,9 @@ ## 3.0.1 (not yet released) +- #54 `triton rbac apply --dev-create-keys-and-profiles` for + experimenting/dev/testing to quickly generate and add user keys and setup + Triton CLI profiles for all users in the RBAC config. - #54 RBAC support, see to start. - `triton rbac info` improvements: better help, use brackets to show non-default roles. diff --git a/examples/rbac-simple/rbac-user-keys/emma.pub b/examples/rbac-simple/rbac-user-keys/emma.pub deleted file mode 100644 index 0b613b8..0000000 --- a/examples/rbac-simple/rbac-user-keys/emma.pub +++ /dev/null @@ -1 +0,0 @@ -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/lib/common.js b/lib/common.js index 91f1854..0908309 100644 --- a/lib/common.js +++ b/lib/common.js @@ -766,6 +766,58 @@ function generatePassword(opts) { } +/** + * Convenience wrapper around `child_process.exec`, mostly oriented to + * run commands using pipes w/o having to deal with logging/error handling. + * + * @param args {Object} + * - cmd {String} Required. The command to run. + * - log {Bunyan Logger} Required. Use to log details at trace level. + * - opts {Object} Optional. child_process.exec execution Options. + * @param cb {Function} `function (err, stdout, stderr)` where `err` here is + * an `errors.InternalError` wrapper around the child_process error. + */ +function execPlus(args, cb) { + assert.object(args, 'args'); + assert.string(args.cmd, 'args.cmd'); + assert.object(args.log, 'args.log'); + assert.optionalObject(args.opts, 'args.opts'); + assert.func(cb); + + var cmd = args.cmd; + var execOpts = args.opts || {}; + var log = args.log; + + log.trace({exec: true, cmd: cmd}, 'exec start'); + child_process.exec(cmd, execOpts, function execPlusCb(err, stdout, stderr) { + log.trace({exec: true, cmd: cmd, err: err, stdout: stdout, + stderr: stderr}, 'exec done'); + if (err) { + var msg = format( + 'exec error:\n' + + '\tcmd: %s\n' + + '\texit status: %s\n' + + '\tstdout:\n%s\n' + + '\tstderr:\n%s', + cmd, err.code, stdout.trim(), stderr.trim()); + cb(new errors.InternalError({message: msg, cause: err}), + stdout, stderr); + } else { + cb(null, stdout, stderr); + } + }); +} + + +function deepEqual(a, b) { + try { + assert.deepEqual(a, b); + } catch (err) { + return false; + } + return true; +} + //---- exports @@ -793,6 +845,8 @@ module.exports = { ansiStylize: ansiStylize, indent: indent, chomp: chomp, - generatePassword: generatePassword + generatePassword: generatePassword, + execPlus: execPlus, + deepEqual: deepEqual }; // vim: set softtabstop=4 shiftwidth=4: diff --git a/lib/do_rbac/do_apply.js b/lib/do_rbac/do_apply.js index cb8384e..528cadc 100644 --- a/lib/do_rbac/do_apply.js +++ b/lib/do_rbac/do_apply.js @@ -15,6 +15,7 @@ var format = require('util').format; var vasync = require('vasync'); var common = require('../common'); +var mod_config = require('../config'); var errors = require('../errors'); var rbac = require('../rbac'); @@ -22,6 +23,7 @@ var ansiStylize = common.ansiStylize; function do_apply(subcmd, opts, args, cb) { + var self = this; if (opts.help) { this.do_help('help', {}, [subcmd], cb); return; @@ -41,6 +43,93 @@ function do_apply(subcmd, opts, args, cb) { rbac.loadRbacConfig, rbac.loadRbacState, rbac.createRbacUpdatePlan, + + /* + * For each user (in the target config): + * - if they don't have a key and one isn't being added in the plan + * already, then we want to create an ssh key pair and add it; and + * - add or replace the '$currprofile-user-$login' Triton CLI + * profile + */ + function devExtendKeys(ctx, next) { + if (!opts.dev_create_keys_and_profiles) { + next(); + return; + } + + ctx.rbacConfig.users.forEach(function devUserKey(user) { + if (user.keys && user.keys.length > 0) { + return; + } + ctx.rbacUpdatePlan.push({ + action: 'generate', + type: 'key', + desc: format('user %s key', user.login), + currProfile: ctx.tritonapi.profile, + user: user.login + }); + }); + next(); + }, + + function devExtendProfiles(ctx, next) { + if (!opts.dev_create_keys_and_profiles) { + next(); + return; + } + + var profiles = mod_config.loadAllProfiles({ + configDir: self.top.configDir, + log: self.log + }); + var profileFromName = {}; + profiles.forEach(function (p) { + profileFromName[p.name] = p; + }); + + ctx.rbacConfig.users.forEach(function devUserKey(user) { + var profileName = format('%s-user-%s', + ctx.tritonapi.profile.name, user.login); + var wantThing = { + name: profileName, + url: ctx.tritonapi.profile.url, + insecure: ctx.tritonapi.profile.insecure, + account: ctx.tritonapi.profile.account, + user: user.login, + // If we are adding a key, we won't have this fingerprint + // until after executing that part of the rbacUpdatePlan. + keyId: user.keys && user.keys[0].fingerprint + }; + var existing = profileFromName[profileName]; + if (existing) { + // If it is the same, avoid no-op update. + if (! common.deepEqual(wantThing, existing)) { + ctx.rbacUpdatePlan.push({ + action: 'update', + type: 'profile', + desc: format('user %s CLI profile', user.login), + id: profileName, + haveThing: existing, + wantThing: wantThing, + user: user.login, + configDir: self.top.configDir + }); + } + } else { + ctx.rbacUpdatePlan.push({ + action: 'create', + type: 'profile', + desc: format('user %s CLI profile', user.login), + id: profileName, + wantThing: wantThing, + user: user.login, + configDir: self.top.configDir + }); + } + }); + next(); + }, + function confirmApply(ctx, next) { if (opts.yes || ctx.rbacUpdatePlan.length === 0) { next(); @@ -52,11 +141,8 @@ function do_apply(subcmd, opts, args, cb) { 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') { + if (c.action === 'update' && c.diff) { extra = format(' (%s)', Object.keys(c.diff).map(function (field) { return c.diff[field] + ' ' + field; @@ -64,9 +150,9 @@ function do_apply(subcmd, opts, args, cb) { } p(' %s %s %s%s', {create: 'Create', 'delete': 'Delete', - update: 'Update'}[c.action], + update: 'Update', generate: 'Generate'}[c.action], c.desc || c.type, - c.id, + c.id || '', extra); }); p(''); @@ -108,6 +194,12 @@ do_apply.options = [ type: 'string', helpArg: 'FILE', help: 'RBAC config JSON file.' + }, + { + names: ['dev-create-keys-and-profiles'], + type: 'bool', + help: 'Convenient option to generate keys and Triton CLI profiles ' + + 'for all users. For experimenting only. See section below.' } ]; @@ -128,6 +220,15 @@ do_apply.help = [ 'as they are replicated across data centers. This can result in unexpected', 'no-op updates with consecutive quick re-runs of this command.', '', + 'The "--dev-create-keys-and-profiles" option is provided for **experimenting', + 'with, developing, or testing** Triton RBAC. It will create a key and setup a ', + 'Triton CLI profile for each user (named "$currprofile-user-$login"). This ', + 'simplies using the CLI as that user:', + ' triton -p coal-user-bob create ...', + ' triton -p coal-user-sarah imgs', + 'Note that proper production usage of RBAC should have the administrator', + 'never seeing each user\'s private key.', + '', 'TODO: Document the rbac.json configuration format.' /* END JSSTYLED */ ].join('\n'); diff --git a/lib/rbac.js b/lib/rbac.js index 5fc50f9..4f74e45 100644 --- a/lib/rbac.js +++ b/lib/rbac.js @@ -13,11 +13,15 @@ var assert = require('assert-plus'); var format = require('util').format; var fs = require('fs'); +var mkdirp = require('mkdirp'); var path = require('path'); +var rimraf = require('rimraf'); var sshpk = require('sshpk'); +var tilde = require('tilde-expansion'); var vasync = require('vasync'); var common = require('./common'); +var mod_config = require('./config'); var errors = require('./errors'); @@ -272,6 +276,7 @@ function loadRbacConfig(ctx, cb) { var stat = fs.statSync(keysFile); } catch (statErr) { if (implicit) { + delete user.keys; nextUser(); return; } @@ -285,6 +290,7 @@ function loadRbacConfig(ctx, cb) { stat = fs.statSync(keysFile); } catch (statErr) { if (implicit) { + delete user.keys; nextUser(); return; } @@ -295,6 +301,7 @@ function loadRbacConfig(ctx, cb) { } if (!stat.isFile()) { if (implicit) { + delete user.keys; nextUser(); return; } @@ -567,7 +574,7 @@ function executeRbacUpdatePlan(ctx, cb) { func: function executeOneChange(c, next) { ctx.log.info({change: c, dryRun: ctx.rbacDryRun}, 'execute rbac update change'); - var extra, delOpts, updateOpts; + var extra, delOpts, updateOpts, i; if (ctx.rbacDryRun) { console.log('[dry-run] %s %s %s', c.action, @@ -582,7 +589,7 @@ function executeRbacUpdatePlan(ctx, cb) { if (! c.wantThing.hasOwnProperty('password')) { c.wantThing.password = common.generatePassword(); } - ctx.cloudapi.createUser(c.wantThing, function (err, user) { + ctx.cloudapi.createUser(c.wantThing, function (err, user_) { if (err) { next(err); return; @@ -601,7 +608,7 @@ function executeRbacUpdatePlan(ctx, cb) { updateOpts[field] = c.wantThing[field]; extra.push(format('%s=%s', field, c.wantThing[field])); }); - ctx.cloudapi.updateUser(updateOpts, function (err, user) { + ctx.cloudapi.updateUser(updateOpts, function (err, user_) { if (err) { next(err); return; @@ -764,6 +771,118 @@ function executeRbacUpdatePlan(ctx, cb) { }); break; + case 'generate-key': + console.log('Generating and adding new SSH key for user %s:', + c.user); + vasync.pipeline({arg: {}, funcs: [ + function vars(ctx2, next2) { + tilde('~', function (s) { + ctx2.homeDir = s; + ctx2.keyName = format('%s user %s', + c.currProfile.name, c.user); + ctx2.keyPath = format('%s/.ssh/%s-user-%s.id_rsa', + ctx2.homeDir, c.currProfile.name, c.user); + next2(); + }); + }, + function rmOldPrivKey(ctx2, next2) { + rimraf(ctx2.keyPath, next2); + }, + function rmOldPubKey(ctx2, next2) { + rimraf(ctx2.keyPath + '.pub', next2); + }, + function generateSshKey(ctx2, next2) { + var cmd = format( + 'ssh-keygen -t rsa -C "%s" -f %s -b 4096', + ctx2.keyName, ctx2.keyPath); + console.log(' Generate 4096 bit RSA key: %s[.pub]', + ctx2.keyPath); + common.execPlus({cmd: cmd, log: ctx.log}, next2); + }, + function loadPubKey(ctx2, next2) { + fs.readFile(ctx2.keyPath + '.pub', 'utf8', + function (err, content) { + ctx2.pubKeyContent = content; + next2(); + }); + }, + function pubKeyCopyInRbacConfigDir(ctx2, next2) { + mkdirp(DEFAULT_RBAC_USER_KEYS_DIR, function (err) { + if (err) { + next(err); + return; + } + var configKeyPath = path.join( + DEFAULT_RBAC_USER_KEYS_DIR, c.user + '.pub'); + console.log(' Copy pubkey to %s', configKeyPath); + fs.writeFile(configKeyPath, ctx2.pubKeyContent, + next2); + }); + }, + function addKey(ctx2, next2) { + ctx.cloudapi.createUserKey({ + userId: c.user, + key: ctx2.pubKeyContent, + name: ctx2.keyName + }, function (err, key) { + if (err) { + next2(err); + return; + } + ctx2.key = key; + console.log(' Created user %s key %s%s', c.user, + key.fingerprint, + key.name ? format(' (%s)', key.name) : ''); + next2(); + }); + }, + function addKeyToRbacConfig(ctx2, next2) { + for (i = 0; i < ctx.rbacConfig.users.length; i++) { + var u = ctx.rbacConfig.users[i]; + if (u.login === c.user) { + assert.ok(!u.keys, + 'expect no keys on user ' + c.user); + u.keys = [ctx2.key]; + break; + } + } + next2(); + } + ]}, next); + break; + + case 'create-profile': + case 'update-profile': + if (!c.wantThing.keyId) { + // Add from the recently generated/added key. + for (i = 0; i < ctx.rbacConfig.users.length; i++) { + var user = ctx.rbacConfig.users[i]; + if (user.login === c.user) { + c.wantThing.keyId = user.keys[0].fingerprint; + break; + } + } + } + + try { + mod_config.validateProfile(c.wantThing); + } catch (err) { + next(err); + break; + } + + try { + mod_config.saveProfileSync({ + configDir: c.configDir, + profile: c.wantThing + }); + } catch (err) { + return next(err); + } + + next(); + break; + default: throw new Error(format( 'unknown action-type: %s-%s', c.action, c.type)); diff --git a/package.json b/package.json index 01b3b77..2f6c25f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "read": "1.0.7", "restify-clients": "1.1.0", "restify-errors": "3.0.0", + "rimraf": "2.4.4", "sshpk": "1.6.x >=1.6.2", "smartdc-auth": "2.2.3", "strsplit": "1.0.0",