This commit is contained in:
Trent Mick 2015-11-18 12:54:44 -08:00
parent 00cdb81287
commit 7c8554bf14
10 changed files with 1055 additions and 108 deletions

View File

@ -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

View 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"

View 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

View 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*"]
}
]
}

View File

@ -760,17 +760,19 @@ CloudApi.prototype.createUser = function createUser(opts, cb) {
* <http://apidocs.joyent.com/cloudapi/#UpdateUser>
*
* @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);
};

View File

@ -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:

137
lib/do_rbac/do_apply.js Normal file
View 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;

View File

@ -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 [<options>]\n' +

View File

@ -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 <https://docs.joyent.com/public-cloud/rbac> for a general start.',
desc: [
'Role-based Access Control (RBAC) commands.',
'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 */
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');

758
lib/rbac.js Normal file
View 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
};