joyent/node-triton#54 'triton rbac apply'
This commit is contained in:
parent
00cdb81287
commit
7c8554bf14
@ -18,10 +18,13 @@
|
|||||||
- `triton rbac policy ...` to show, create, edit and delete policies.
|
- `triton rbac policy ...` to show, create, edit and delete policies.
|
||||||
- `triton rbac keys` to list all RBAC user SSH keys.
|
- `triton rbac keys` to list all RBAC user SSH keys.
|
||||||
- `triton rbac key ...` to show, create, edit and delete user 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
|
- `triton rbac {instance,image,network,package,}role-tags ...` to list
|
||||||
and manage role tags on each of those resources.
|
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
|
- #55 Update of smartdc-auth/sshpk deps, removal of duplicated code for
|
||||||
composing Authorization headers
|
composing Authorization headers
|
||||||
|
|
||||||
|
27
examples/rbac-simple/README.md
Normal file
27
examples/rbac-simple/README.md
Normal file
@ -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"
|
||||||
|
|
1
examples/rbac-simple/rbac-user-keys/emma.pub
Normal file
1
examples/rbac-simple/rbac-user-keys/emma.pub
Normal file
@ -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
|
43
examples/rbac-simple/rbac.json
Normal file
43
examples/rbac-simple/rbac.json
Normal file
@ -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*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -760,17 +760,19 @@ CloudApi.prototype.createUser = function createUser(opts, cb) {
|
|||||||
* <http://apidocs.joyent.com/cloudapi/#UpdateUser>
|
* <http://apidocs.joyent.com/cloudapi/#UpdateUser>
|
||||||
*
|
*
|
||||||
* @param {Object} opts (object) user object containing:
|
* @param {Object} opts (object) user object containing:
|
||||||
* - {String} id (required) for your user.
|
* - {String} id (required) for your user. This can be either the user
|
||||||
* - {String} email (optional) for the user.
|
* login, or the user id (UUID)
|
||||||
* - {String} companyName (optional) for the user.
|
* - {String} login (optional)
|
||||||
* - {String} firstName (optional) for the user.
|
* - {String} email (optional)
|
||||||
* - {String} lastName (optional) for the user.
|
* - {String} companyName (optional)
|
||||||
* - {String} address (optional) for the user.
|
* - {String} firstName (optional)
|
||||||
* - {String} postalCode (optional) for the user.
|
* - {String} lastName (optional)
|
||||||
* - {String} city (optional) for the user.
|
* - {String} address (optional)
|
||||||
* - {String} state (optional) for the user.
|
* - {String} postalCode (optional)
|
||||||
* - {String} country (optional) for the user.
|
* - {String} city (optional)
|
||||||
* - {String} phone (optional) for the user.
|
* - {String} state (optional)
|
||||||
|
* - {String} country (optional)
|
||||||
|
* - {String} phone (optional)
|
||||||
* @param {Function} cb of the form `function (err, user, res)`
|
* @param {Function} cb of the form `function (err, user, res)`
|
||||||
*/
|
*/
|
||||||
CloudApi.prototype.updateUser = function updateUser(opts, cb) {
|
CloudApi.prototype.updateUser = function updateUser(opts, cb) {
|
||||||
@ -780,6 +782,7 @@ CloudApi.prototype.updateUser = function updateUser(opts, cb) {
|
|||||||
assert.func(cb, 'cb');
|
assert.func(cb, 'cb');
|
||||||
|
|
||||||
var update = {
|
var update = {
|
||||||
|
login: opts.login,
|
||||||
email: opts.email,
|
email: opts.email,
|
||||||
companyName: opts.companyName,
|
companyName: opts.companyName,
|
||||||
firstName: opts.firstName,
|
firstName: opts.firstName,
|
||||||
@ -837,7 +840,7 @@ CloudApi.prototype.listUserKeys = function listUserKeys(opts, cb) {
|
|||||||
assert.func(cb, 'cb');
|
assert.func(cb, 'cb');
|
||||||
|
|
||||||
var endpoint = format('/%s/users/%s/keys', this.account, opts.userId);
|
var endpoint = format('/%s/users/%s/keys', this.account, opts.userId);
|
||||||
this._passThrough(endpoint, opts, cb);
|
this._passThrough(endpoint, {}, cb);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
var assert = require('assert-plus');
|
var assert = require('assert-plus');
|
||||||
var child_process = require('child_process');
|
var child_process = require('child_process');
|
||||||
|
var crypto = require('crypto');
|
||||||
var fs = require('fs');
|
var fs = require('fs');
|
||||||
var os = require('os');
|
var os = require('os');
|
||||||
var path = require('path');
|
var path = require('path');
|
||||||
@ -502,7 +503,7 @@ function promptYesNo(opts_, cb) {
|
|||||||
case '\n':
|
case '\n':
|
||||||
case '\r':
|
case '\r':
|
||||||
case '\u0004':
|
case '\u0004':
|
||||||
// They've finished typing their answer
|
// EOT. They've finished typing their answer
|
||||||
postInput();
|
postInput();
|
||||||
var answer = input.toLowerCase();
|
var answer = input.toLowerCase();
|
||||||
if (answer === '' && opts.default) {
|
if (answer === '' && opts.default) {
|
||||||
@ -517,12 +518,23 @@ function promptYesNo(opts_, cb) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case '\u0003':
|
case '\u0003': // Ctrl C
|
||||||
// Ctrl C
|
|
||||||
postInput();
|
postInput();
|
||||||
finish(false);
|
finish(false);
|
||||||
break;
|
break;
|
||||||
|
case '\u007f': // DEL
|
||||||
|
input = input.slice(0, -1);
|
||||||
|
stdout.clearLine();
|
||||||
|
stdout.cursorTo(0);
|
||||||
|
stdout.write(opts.msg);
|
||||||
|
stdout.write(input);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
|
// Rule out special ASCII chars.
|
||||||
|
var code = ch.charCodeAt(0);
|
||||||
|
if (0 <= code && code <= 31) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
// More plaintext characters
|
// More plaintext characters
|
||||||
stdout.write(ch);
|
stdout.write(ch);
|
||||||
input += 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
|
//---- exports
|
||||||
|
|
||||||
@ -743,6 +779,7 @@ module.exports = {
|
|||||||
editInEditor: editInEditor,
|
editInEditor: editInEditor,
|
||||||
ansiStylize: ansiStylize,
|
ansiStylize: ansiStylize,
|
||||||
indent: indent,
|
indent: indent,
|
||||||
chomp: chomp
|
chomp: chomp,
|
||||||
|
generatePassword: generatePassword
|
||||||
};
|
};
|
||||||
// vim: set softtabstop=4 shiftwidth=4:
|
// vim: set softtabstop=4 shiftwidth=4:
|
||||||
|
137
lib/do_rbac/do_apply.js
Normal file
137
lib/do_rbac/do_apply.js
Normal file
@ -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>]',
|
||||||
|
'',
|
||||||
|
'{{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;
|
@ -58,41 +58,12 @@ var vasync = require('vasync');
|
|||||||
|
|
||||||
var common = require('../common');
|
var common = require('../common');
|
||||||
var errors = require('../errors');
|
var errors = require('../errors');
|
||||||
|
var rbac = require('../rbac');
|
||||||
|
|
||||||
var ansiStylize = common.ansiStylize;
|
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) {
|
function do_info(subcmd, opts, args, cb) {
|
||||||
if (opts.help) {
|
if (opts.help) {
|
||||||
this.do_help('help', {}, [subcmd], cb);
|
this.do_help('help', {}, [subcmd], cb);
|
||||||
@ -104,60 +75,23 @@ function do_info(subcmd, opts, args, cb) {
|
|||||||
var log = this.log;
|
var log = this.log;
|
||||||
|
|
||||||
var context = {
|
var context = {
|
||||||
|
log: this.log,
|
||||||
tritonapi: this.top.tritonapi,
|
tritonapi: this.top.tritonapi,
|
||||||
cloudapi: this.top.tritonapi.cloudapi
|
cloudapi: this.top.tritonapi.cloudapi,
|
||||||
|
rbacStateAll: opts.all
|
||||||
};
|
};
|
||||||
vasync.pipeline({arg: context, funcs: [
|
vasync.pipeline({arg: context, funcs: [
|
||||||
gatherRbacBasicInfo,
|
|
||||||
function gatherUserKeys(ctx, next) {
|
rbac.loadRbacState,
|
||||||
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();
|
|
||||||
},
|
|
||||||
function printInfo(ctx, next) {
|
function printInfo(ctx, next) {
|
||||||
var i;
|
var i;
|
||||||
log.trace({
|
log.trace({rbacState: ctx.rbacState}, 'rbacState');
|
||||||
users: ctx.users,
|
|
||||||
policies: ctx.policies,
|
|
||||||
roles: ctx.roles
|
|
||||||
}, 'rbac info data');
|
|
||||||
|
|
||||||
console.log('users (%d):', ctx.users.length);
|
console.log('users (%d):', ctx.rbacState.users.length);
|
||||||
tabula.sortArrayOfObjects(ctx.users, ['name']);
|
tabula.sortArrayOfObjects(ctx.rbacState.users, ['name']);
|
||||||
for (i = 0; i < ctx.users.length; i++) {
|
for (i = 0; i < ctx.rbacState.users.length; i++) {
|
||||||
var user = ctx.users[i];
|
var user = ctx.rbacState.users[i];
|
||||||
|
|
||||||
var userExtra = [];
|
var userExtra = [];
|
||||||
if (user.firstName || user.lastName) {
|
if (user.firstName || user.lastName) {
|
||||||
@ -197,10 +131,10 @@ function do_info(subcmd, opts, args, cb) {
|
|||||||
userExtra, roleInfo);
|
userExtra, roleInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('roles (%d):', ctx.roles.length);
|
console.log('roles (%d):', ctx.rbacState.roles.length);
|
||||||
tabula.sortArrayOfObjects(ctx.roles, ['name']);
|
tabula.sortArrayOfObjects(ctx.rbacState.roles, ['name']);
|
||||||
for (i = 0; i < ctx.roles.length; i++) {
|
for (i = 0; i < ctx.rbacState.roles.length; i++) {
|
||||||
var role = ctx.roles[i];
|
var role = ctx.rbacState.roles[i];
|
||||||
|
|
||||||
var policyInfo;
|
var policyInfo;
|
||||||
if (role.policies.length === 1) {
|
if (role.policies.length === 1) {
|
||||||
@ -214,10 +148,10 @@ function do_info(subcmd, opts, args, cb) {
|
|||||||
policyInfo);
|
policyInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('policies (%d):', ctx.policies.length);
|
console.log('policies (%d):', ctx.rbacState.policies.length);
|
||||||
tabula.sortArrayOfObjects(ctx.policies, ['name']);
|
tabula.sortArrayOfObjects(ctx.rbacState.policies, ['name']);
|
||||||
for (i = 0; i < ctx.policies.length; i++) {
|
for (i = 0; i < ctx.rbacState.policies.length; i++) {
|
||||||
var policy = ctx.policies[i];
|
var policy = ctx.rbacState.policies[i];
|
||||||
var noRules = '';
|
var noRules = '';
|
||||||
if (policy.rules.length === 0) {
|
if (policy.rules.length === 0) {
|
||||||
noRules = ' ' + ansiStylize('no rules', 'red');
|
noRules = ' ' + ansiStylize('no rules', 'red');
|
||||||
@ -258,7 +192,7 @@ do_info.options = [
|
|||||||
|
|
||||||
do_info.help = (
|
do_info.help = (
|
||||||
/* BEGIN JSSTYLED */
|
/* BEGIN JSSTYLED */
|
||||||
'Print an account RBAC summary.\n' +
|
'Show current RBAC state.\n' +
|
||||||
'\n' +
|
'\n' +
|
||||||
'Usage:\n' +
|
'Usage:\n' +
|
||||||
' {{name}} info [<options>]\n' +
|
' {{name}} info [<options>]\n' +
|
||||||
|
@ -22,9 +22,11 @@ function RbacCLI(top) {
|
|||||||
Cmdln.call(this, {
|
Cmdln.call(this, {
|
||||||
name: top.name + ' rbac',
|
name: top.name + ' rbac',
|
||||||
/* BEGIN JSSTYLED */
|
/* BEGIN JSSTYLED */
|
||||||
desc: 'Role-based Access Control (RBAC) commands. *Experimental.*\n' +
|
desc: [
|
||||||
'\n' +
|
'Role-based Access Control (RBAC) commands.',
|
||||||
'See <https://docs.joyent.com/public-cloud/rbac> for a general start.',
|
'See <https://docs.joyent.com/public-cloud/rbac> for a general start.',
|
||||||
|
'**Warning: `triton rbac ...` is experimental, not well tested and in flux.**'
|
||||||
|
].join('\n'),
|
||||||
/* END JSSTYLED */
|
/* END JSSTYLED */
|
||||||
helpOpts: {
|
helpOpts: {
|
||||||
minHelpCol: 24 /* line up with option help */
|
minHelpCol: 24 /* line up with option help */
|
||||||
@ -32,6 +34,7 @@ function RbacCLI(top) {
|
|||||||
helpSubcmds: [
|
helpSubcmds: [
|
||||||
'help',
|
'help',
|
||||||
'info',
|
'info',
|
||||||
|
'apply',
|
||||||
{ group: 'RBAC Resources' },
|
{ group: 'RBAC Resources' },
|
||||||
'users',
|
'users',
|
||||||
'user',
|
'user',
|
||||||
@ -59,6 +62,7 @@ RbacCLI.prototype.init = function init(opts, args, cb) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
RbacCLI.prototype.do_info = require('./do_info');
|
RbacCLI.prototype.do_info = require('./do_info');
|
||||||
|
RbacCLI.prototype.do_apply = require('./do_apply');
|
||||||
|
|
||||||
RbacCLI.prototype.do_users = require('./do_users');
|
RbacCLI.prototype.do_users = require('./do_users');
|
||||||
RbacCLI.prototype.do_user = require('./do_user');
|
RbacCLI.prototype.do_user = require('./do_user');
|
||||||
|
758
lib/rbac.js
Normal file
758
lib/rbac.js
Normal file
@ -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 `<user>.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
|
||||||
|
};
|
Reference in New Issue
Block a user