diff --git a/README.md b/README.md index 3130e14f..2f00fa18 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ The focus of the tool is to streamline and easy the communication with Commerce * Code deployment and code version management * System job execution and monitoring (site import) * Custom job execution and monitoring +* Exploring Account Manager orgs and management of users and roles * JavaScript API # How do I get set up? # @@ -88,6 +89,48 @@ Use the following snippet as your client's permission set, replace `my_client_id "methods": ["get"], "read_attributes": "(**)", "write_attributes": "(**)" + }, + { + "resource_id":"/role_search", + "methods":["post"], + "read_attributes":"(**)", + "write_attributes":"(**)" + }, + { + "resource_id":"/roles/*", + "methods":["get"], + "read_attributes":"(**)", + "write_attributes":"(**)" + }, + { + "resource_id":"/roles/*/user_search", + "methods":["post"], + "read_attributes":"(**)", + "write_attributes":"(**)" + }, + { + "resource_id":"/roles/*/users/*", + "methods":["put","delete"], + "read_attributes":"(**)", + "write_attributes":"(**)" + }, + { + "resource_id":"/user_search", + "methods":["post"], + "read_attributes":"(**)", + "write_attributes":"(**)" + }, + { + "resource_id":"/users", + "methods":["get"], + "read_attributes":"(**)", + "write_attributes":"(**)" + }, + { + "resource_id":"/users/*", + "methods":["put","get","patch","delete"], + "read_attributes":"(**)", + "write_attributes":"(**)" } ] } @@ -226,6 +269,14 @@ Use `sfcc-ci --help` to get started and see the list of commands available: code:activate [options] Activate the custom code version on a Commerce Cloud instance job:run [options] [job_parameters...] Starts a job execution on a Commerce Cloud instance job:status [options] Get the status of a job execution on a Commerce Cloud instance + org:list [options] List all orgs eligible to manage + role:list [options] List roles + role:grant [options] Grant a role to a user + role:revoke [options] Revoke a role from a user + user:list [options] List users eligible to manage + user:create [options] Create a new user + user:update [options] Update a user + user:delete [options] Delete a user Environment: diff --git a/bin/test-cli.sh b/bin/test-cli.sh index 3435f7a9..21454773 100755 --- a/bin/test-cli.sh +++ b/bin/test-cli.sh @@ -788,3 +788,25 @@ else echo -e "\t> FAILED" exit 1 fi + +############################################################################### +###### Testing ´sfcc-ci org:list´ +############################################################################### + +echo "Testing command ´sfcc-ci org:list´ without option:" +node ./cli.js org:list +if [ $? -eq 0 ]; then + echo -e "\t> OK" +else + echo -e "\t> FAILED" + exit 1 +fi + +echo "Testing command ´sfcc-ci org:list --org ´ with invalid org (expected to fail):" +node ./cli.js org:list --org does_not_exist +if [ $? -eq 1 ]; then + echo -e "\t> OK" +else + echo -e "\t> FAILED" + exit 1 +fi \ No newline at end of file diff --git a/cli.js b/cli.js index b0705f3f..6f5b9e93 100755 --- a/cli.js +++ b/cli.js @@ -7,6 +7,12 @@ program .option('-D, --debug', 'enable verbose output', function() { process.env.DEBUG = true; process.env.NODE_DEBUG = 'request'; + }) + .option('--selfsigned', 'allow connection to hosts using self-signed certificates', function() { + process.env.SFCC_ALLOW_SELF_SIGNED = true; + }) + .option('-I, --ignorewarnings', 'ignore any warnings logged to the console', function() { + process.env.SFCC_IGNORE_WARNINGS = true; }); program @@ -821,6 +827,401 @@ program console.log(); }); +program + .command('org:list') + .description('List all orgs eligible to manage') + .option('-o, --org ','Organization to get details for') + .option('-j, --json', 'Formats the output in json') + .option('-s, --sortby ', 'Sort by specifying any field') + .action(function(options) { + var org = ( options.org ? options.org : null ); + var asJson = ( options.json ? options.json : false ); + var sortby = ( options.sortBy ? options.sortBy : null ); + require('./lib/org').cli.list(org, asJson, sortby); + }).on('--help', function() { + console.log(''); + console.log(' Examples:'); + console.log(); + console.log(' $ sfcc-ci org:list') + console.log(' $ sfcc-ci org:list --org "my-org"') + console.log(); + }); + +program + .command('role:list') + .description('List roles') + .option('-i, --instance ','Instance to return roles for') + .option('-c, --count ','Max count of list items (default is 25)') + .option('-r, --role ','Role to get details for') + .option('-j, --json', 'Formats the output in json') + .option('-s, --sortby ', 'Sort by specifying any field') + .option('-v, --verbose', 'Outputs additional details of a role') + .action(function(options) { + var instance = ( options.instance ? require('./lib/instance').getInstance(options.instance) : null ); + var count = ( options.count ? options.count : null ); + var role = ( options.role ? options.role : null ); + var asJson = ( options.json ? options.json : false ); + var sortby = ( options.sortBy ? options.sortBy : null ); + var verbose = ( options.verbose ? options.verbose : false ); + + if ( options.instance ) { + require('./lib/role').cli.listLocal(instance, role, null, role, sortby, count, asJson, verbose); + } else { + require('./lib/role').cli.list(count, asJson); + } + }).on('--help', function() { + console.log(''); + console.log(' Details:'); + console.log(); + console.log(' List roles available to grant to users. By default roles from Account Manager eligible'); + console.log(' to grant to users are returned. If the --instance option is used, roles defined on that'); + console.log(' Commerce Cloud instance are returned.'); + console.log(); + console.log(' Use --role to get details of a single role. Use --verbose to show permissions the'); + console.log(' role includes and the users on the instance granted with that role.'); + console.log(''); + console.log(' Examples:'); + console.log(); + console.log(' $ sfcc-ci role:list'); + console.log(' $ sfcc-ci role:list --instance my-instance.demandware.net'); + console.log(' $ sfcc-ci role:list --instance my-instance.demandware.net --role "Administrator"') + console.log(); + }); + +program + .command('role:grant') + .description('Grant a role to a user') + .option('-i, --instance ','Instance to grant a user a role to') + .option('-l, --login ','Login of user to grant role to') + .option('-r, --role ','Role to grant') + .option('-s, --scope ','Scope of role to grant') + .option('-j, --json', 'Formats the output in json') + .action(function(options) { + var instance = ( options.instance ? require('./lib/instance').getInstance(options.instance) : null ); + var login = ( options.login ? options.login : null ); + var role = ( options.role ? options.role : null ); + var scope = ( options.scope ? options.scope : null ); + var asJson = ( options.json ? options.json : false ); + + if ( instance && scope ) { + require('./lib/log').error('Ambiguous options. Use -h,--help for help.'); + } else if ( instance ) { + require('./lib/user').cli.grantLocal(instance, login, role, asJson); + } else { + require('./lib/user').cli.grant(login, role, scope, asJson); + } + }).on('--help', function() { + console.log(''); + console.log(' Details:'); + console.log(); + console.log(' Grants a role to a user in Account Manager. Use additional --scope to grant the role'); + console.log(' to a specific scope. This allows to limit the role for a specific Commerce Cloud instance'); + console.log(' or a group of instances. Scopes are only supported by specific roles in Account Manager.'); + console.log(); + console.log(' Use --instance to grant a role to a user on a Commerce Cloud instance.'); + console.log(''); + console.log(' Examples:'); + console.log(); + console.log(' $ sfcc-ci role:grant --login the-user --role the-role'); + console.log(' $ sfcc-ci role:grant --login the-user --role the-role --scope zzzz_dev'); + console.log(' $ sfcc-ci role:grant --login the-user --role the-role --scope zzzz_*'); + console.log(' $ sfcc-ci role:grant --login the-user --role the-role --scope "zzzz_s01,zzzz_s02"'); + console.log(' $ sfcc-ci role:grant --instance my-instance.demandware.net --login the-user --role the-role'); + console.log(); + }); + +program + .command('role:revoke') + .description('Revoke a role from a user') + .option('-i, --instance ','Instance to revoke a user a role from') + .option('-l, --login ','Login of user to revoke role from') + .option('-r, --role ','Role to revoke') + .option('-s, --scope ','Scope of role to revoke') + .option('-j, --json', 'Formats the output in json') + .action(function(options) { + var instance = ( options.instance ? require('./lib/instance').getInstance(options.instance) : null ); + var login = ( options.login ? options.login : null ); + var role = ( options.role ? options.role : null ); + var scope = ( options.scope ? options.scope : null ); + var asJson = ( options.json ? options.json : false ); + + if ( instance && scope ) { + require('./lib/log').error('Ambiguous options. Use -h,--help for help.'); + } else if ( instance ) { + require('./lib/user').cli.revokeLocal(instance, login, role, asJson); + } else { + require('./lib/user').cli.revoke(login, role, scope, asJson); + } + }).on('--help', function() { + console.log(''); + console.log(' Details:'); + console.log(); + console.log(' Revokes a role from a user in Account Manager. Use additional --scope to reduce'); + console.log(' the scope of a role. This allows to limit the role to specific Commerce Cloud'); + console.log(' instances. Multiple instances or a range of instances can be specified.'); + console.log(''); + console.log(' Use --instance to revoke a role from a user on a Commerce Cloud instance.'); + console.log(''); + console.log(' Examples:'); + console.log(); + console.log(' $ sfcc-ci role:revoke --login the-user --role the-role'); + console.log(' $ sfcc-ci role:revoke --login the-user --role the-role --scope zzzz_dev'); + console.log(' $ sfcc-ci role:revoke --login the-user --role the-role --scope zzzz_*'); + console.log(' $ sfcc-ci role:revoke --login the-user --role the-role --scope "zzzz_s01,zzzz_s02"'); + console.log(' $ sfcc-ci role:revoke --instance my-instance.demandware.net --login the-user --role the-role'); + console.log(); + }); + +program + .command('user:list') + .description('List users eligible to manage') + .option('-c, --count ','Max count of list items (default is 25)') + .option('--start ','Zero-based index of first item to return (default is 0)') + .option('-o, --org ','Org to return users for (only works in combination with )') + .option('-i, --instance ','Instance to search users for. Can be an instance alias.') + .option('-l, --login ','Login of a user to get details for') + .option('-r, --role ','Limit users to a certain role') + .option('-q, --query ','Query to search users for') + .option('-j, --json', 'Formats the output in json') + .option('-s, --sortby ', 'Sort by specifying any field') + .action(function(options) { + var count = ( options.count ? options.count : null ); + var start = ( options.start ? options.start : null ); + var org = options.org; + var instance = ( options.instance ? require('./lib/instance').getInstance(options.instance) : null ); + var login = options.login; + var role = options.role; + var query = ( options.query ? JSON.parse(options.query) : null ); + var asJson = ( options.json ? options.json : false ); + var sortby = ( options.sortby ? options.sortby : null ); + if ( instance && login ) { + // get users on the instance with role + require('./lib/user').cli.searchLocal(instance, login, query, null, null, null, null, asJson); + } else if ( instance && !login ) { + // get users on instance + require('./lib/user').cli.searchLocal(instance, login, query, role, sortby, count, start, asJson); + } else if ( ( org && role ) || ( !org && role ) || !( org && role ) ) { + // get users from AM + require('./lib/user').cli.list(org, role, login, count, asJson, sortby); + } else { + require('./lib/log').error('Ambiguous options. Please consult the help using --help.'); + } + }).on('--help', function() { + console.log(''); + console.log(' Details:'); + console.log(); + console.log(' By default users in the Account Manager organization the user is eligible'); + console.log(' to manage are being returned. Depending on the number of users the list may'); + console.log(' be large. Use option --count to limit the number of users.'); + console.log(); + console.log(' Use --login to get details of a single user.'); + console.log(); + console.log(' If options --org and --role are used, you can filter users by organization and'); + console.log(' role. --org only works in combination with --role. Only enabled users are returned.'); + console.log(); + console.log(' If option --instance is used, local users from this Commerce Cloud instance'); + console.log(' are being returned. Use --query to narrow down the users.'); + console.log(); + console.log(' Use options --instance and --login to get details of a local user on the'); + console.log(' Commerce Cloud instance.'); + console.log(''); + console.log(' Examples:'); + console.log(); + console.log(' $ sfcc-ci user:list') + console.log(' $ sfcc-ci user:list -c 100') + console.log(' $ sfcc-ci user:list -c 200 --start 200') + console.log(' $ sfcc-ci user:list --sortby "lastName"') + console.log(' $ sfcc-ci user:list --json') + console.log(' $ sfcc-ci user:list --instance my-instance --login local-user'); + console.log(' $ sfcc-ci user:list --instance my-instance --query \'{"term_query":' + + '{"fields":["external_id"],"operator":"is_null"}}\' --json'); + console.log(' $ sfcc-ci user:list --instance my-instance --query \'{"term_query":' + + '{"fields":["disabled"],"operator":"is","values":[true]}}\''); + console.log(' $ sfcc-ci user:list --instance my-instance --role Administrator'); + console.log(' $ sfcc-ci user:list --login my-login'); + console.log(' $ sfcc-ci user:list --login my-login -j'); + console.log(' $ sfcc-ci user:list --role account-admin'); + console.log(' $ sfcc-ci user:list --org my-org --role bm-user'); + console.log(); + }); + +program + .command('user:create') + .description('Create a new user') + .option('-o, --org ', 'Org to create the user for') + .option('-i, --instance ','Instance to create the user on. Can be an instance alias.') + .option('-l, --login ','Login of the user') + .option('-u, --user ', 'User details as json') + .option('-j, --json', 'Formats the output in json') + .action(function(options) { + var org = ( options.org ? options.org : null ); + var instance = ( options.instance ? require('./lib/instance').getInstance(options.instance) : null ); + var login = ( options.login ? options.login : null ); + var user = ( options.user ? JSON.parse(options.user) : null ); + var asJson = ( options.json ? options.json : false ); + if ( ( !org && !instance ) || ( org && instance ) ) { + require('./lib/log').error('Ambiguous options. Pass either -o,--org or -i,--instance.'); + } else if ( !login ) { + require('./lib/log').error('Login missing. Please pass a login using -l,--login.'); + } else if ( instance && login ) { + // create locally + require('./lib/user').cli.createLocal(instance, login, user, asJson); + } else if ( org && login ) { + // create in AM + require('./lib/user').cli.create(org, user, login, null, null, asJson); + } else { + require('./lib/log').error('Ambiguous options. Use -h,--help for help.'); + } + }).on('--help', function() { + console.log(''); + console.log(' Details:'); + console.log(); + console.log(' Create a new user.'); + console.log(''); + console.log(' If an org is passed using -o,--org, the user will be created in the Account Manager'); + console.log(' for the passed org. The login (an email) must be unique. After a successful'); + console.log(' creation the user will receive a confirmation e-mail with a link to activate his'); + console.log(' account. Default roles of the user in Account Manager are "xchange-user" and "doc-user".'); + console.log(''); + console.log(' Use -i,--instance to create a local user is on the Commerce Cloud instance.'); + console.log(' The login must be unique. By default no roles will be assigned to the user on the instance.'); + console.log(''); + console.log(' You should pass details of the user in json (option -u,--user).'); + console.log(''); + console.log(' Examples:'); + console.log(); + console.log(' $ sfcc-ci user:create --org my-org --login jdoe@email.org --user \'{"firstName":' + + '"John", "lastName":"Doe", "roles": ["xchange-user"]}\''); + console.log(' $ sfcc-ci user:create --instance my-instance --login "my-user" --user \'{"email":' + + '"jdoe@email.org", "first_name":"John", "last_name":"Doe", "roles": ["Administrator"]}\''); + console.log(); + }); + +program + .command('user:update') + .description('Update a user') + .option('-i, --instance ','Instance to update the user on. Can be an instance alias.') + .option('-l, --login ','Login of the user') + .option('-c, --changes ', 'Changes to user details as json') + .option('-j, --json', 'Formats the output in json') + .option('-N, --noprompt','No prompt to confirm update') + .action(function(options) { + var instance = ( options.instance ? require('./lib/instance').getInstance(options.instance) : null ); + var login = ( options.login ? options.login : null ); + var changes = ( options.changes ? JSON.parse(options.changes) : null ); + var asJson = ( options.json ? options.json : false ); + var noPrompt = ( options.noprompt ? options.noprompt : false ); + + var updateUser = function(instance, login, changes, asJson) { + if ( instance ) { + require('./lib/user').cli.updateLocal(instance, login, changes, asJson); + } else { + require('./lib/user').cli.update(login, changes, asJson); + } + }; + + if ( !login ) { + require('./lib/log').error('Login missing. Please pass a login using -l,--login.'); + } else if ( !changes ) { + require('./lib/log').error('Changes missing. Please specify changes using -c,--change.'); + } else if ( noPrompt && !instance ) { + updateUser(instance, login, changes, asJson); + } else { + prompt({ + type : 'confirm', + name : 'ok', + default : false, + message : 'Update user ' + login + ( instance ? ' on ' + instance : '' ) + '. Are you sure?' + }).then((answers) => { + if (answers['ok']) { + updateUser(instance, login, changes, asJson); + } + }); + } + }).on('--help', function() { + console.log(''); + console.log(' Details:'); + console.log(); + console.log(' Updates an existing user'); + console.log(''); + console.log(' If --instance is not passed the user is updated in Account Manager.'); + console.log(' This requires permissions in Account Manager to adminstrate the org,'); + console.log(' the user belongs to. You should pass changes to the user details in') + console.log(' json (option -c,--changes).'); + console.log(''); + console.log(' Pass an --instance to update a local user on a Commerce Cloud instance.'); + console.log(''); + console.log(' Examples:'); + console.log(); + console.log(' $ sfcc-ci user:update --login jdoe@email.org --changes \'{"userState": "ENABLED"}\''); + console.log(' $ sfcc-ci user:update -i my-instance.demandware.net -l jdoe@email.org -c ' + + '\'{"disabled": true}\''); + console.log(); + }); + +program + .command('user:delete') + .description('Delete a user') + .option('-i, --instance ','Instance to delete the user from. Can be an instance alias.') + .option('-l, --login ','Login of the user to delete') + .option('-p, --purge','Purge the user') + .option('-j, --json', 'Formats the output in json') + .option('-N, --noprompt','No prompt to confirm deletion') + .action(function(options) { + var instance = ( options.instance ? require('./lib/instance').getInstance(options.instance) : null ); + var login = options.login; + var purge = ( options.purge ? options.purge : false ); + var asJson = ( options.json ? options.json : false ); + var noPrompt = ( options.noprompt ? options.noprompt : false ); + + var deleteUser = function(instance, login, purge, asJson) { + if ( instance ) { + require('./lib/user').cli.deleteLocal(instance, login, asJson); + } else { + require('./lib/user').cli.delete(login, purge, asJson); + } + }; + + if ( !login ) { + require('./lib/log').error('Missing required --login. Use -h,--help for help.'); + } else if ( noPrompt ) { + deleteUser(instance, login, purge, asJson); + } else { + prompt({ + type : 'confirm', + name : 'ok', + default : false, + message : ( purge ? 'Purge' : 'Delete' ) + ' user ' + login + ( instance ? ' on ' + instance : '' ) + + '. Are you sure?' + }).then((answers) => { + if (answers['ok']) { + deleteUser(instance, login, purge, asJson); + } + }); + } + }).on('--help', function() { + console.log(''); + console.log(' Details:'); + console.log(); + console.log(' Delete a user.'); + console.log(''); + console.log(' If --instance is not passed the user is deleted in Account Manager.'); + console.log(' This requires permissions in Account Manager to adminstrate the org,'); + console.log(' the user belongs to. The user is only marked as deleted and cannot'); + console.log(' log into Account Manager anymore.'); + console.log(' Use option --purge to completely purge the user.'); + console.log(''); + console.log(' Pass an --instance to delete a local user from a Commerce Cloud instance.'); + console.log(''); + console.log(' Examples:'); + console.log(); + console.log(' $ sfcc-ci user:delete --login jdoe@email.org'); + console.log(' $ sfcc-ci user:delete --instance my-instance.demandware.net --login jdoe@email.org'); + console.log(' $ sfcc-ci user:delete --login jdoe@email.org --purge'); + console.log(); + }); + + program.on('--help', function() { console.log(''); console.log(' Environment:'); diff --git a/lib/auth.js b/lib/auth.js index 044bca09..cd3e8080 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -624,6 +624,7 @@ function getOauth2RedirectHTML() { module.exports.auth = auth; module.exports.renew = renew; +module.exports.getAMHost = getAMHost; module.exports.getToken = getToken; module.exports.getAccessToken = getAccessToken; module.exports.getRefreshToken = getRefreshToken; diff --git a/lib/log.js b/lib/log.js index 862c7d50..4a8abebb 100644 --- a/lib/log.js +++ b/lib/log.js @@ -86,7 +86,10 @@ module.exports.info = function() { // warn in yellow module.exports.warn = function() { arguments[0] = warn('Warning:', arguments[0]); - console.warn.apply(null, arguments); + // log only, if not explicitly ignored + if (!process.env.SFCC_IGNORE_WARNINGS) { + console.warn.apply(null, arguments); + } } // error in red diff --git a/lib/ocapi.js b/lib/ocapi.js index e4daf9d4..f2b51d94 100644 --- a/lib/ocapi.js +++ b/lib/ocapi.js @@ -35,7 +35,7 @@ function getOptions(host, path, token, method) { } // allow self-signed certificates, if needed (only supported for configuration via dw.json) - if ( dwjson['self-signed'] ) { + if ( dwjson['self-signed'] || process.env.SFCC_ALLOW_SELF_SIGNED ) { opts['strictSSL'] = false; console.warn('Allow self-signed certificates. Be caucious as this may expose secure information to an ' + diff --git a/lib/org.js b/lib/org.js new file mode 100644 index 00000000..10e7ae31 --- /dev/null +++ b/lib/org.js @@ -0,0 +1,201 @@ +var request = require('request'); +var util = require('util'); + +var auth = require('./auth'); +var console = require('./log'); + +const API_BASE = '/dw/rest/v1'; +const ORG_ALLOWED_READ_PROPERTIES = [ 'id', 'name', 'realms', 'twoFARoles' ]; + +/** + * Helper to capture most-common responses due to errors which occur across resources. In case a well-known issue + * was identified, the function returns an Error object holding detailed information about the error. A callback + * function can be passed optionally, the error and the response are passed as parameters to the callback function. + * + * @param {Object} err + * @param {Object} response + * @param {Function} callback + * @return {Error} the error or null + */ +function captureCommonErrors(err, response, callback) { + var error = null; + if (err && !response) { + error = new Error('The operation could not be performed properly. ' + ( process.env.DEBUG ? err : '' )); + } else if (response.statusCode === 401) { + error = new Error('Authentication invalid. Please (re-)authenticate by running ' + + '´sfcc-ci auth:login´ or ´sfcc-ci client:auth´'); + } + // just return the error, in case no callback is passed + if (!callback) { + return error; + } + callback(error, response); +} + +/** + * Contructs the http request options and ensure shared request headers across requests, such as authentication. + * + * @param {String} path + * @param {String} token + * @param {String} method + * @return {Object} the request options + */ +function getOptions(path, token, method) { + var opts = { + uri: 'https://' + auth.getAMHost() + path, + auth: { + bearer: ( token ? token : null ) + }, + strictSSL: false, + method: method, + json: true + }; + return opts; +} + +/** + * Retrieves detals of an org + * + * @param {String} org the name of the org + * @param {Function} callback the callback to execute, the error and the org are available as arguments to the callback function + */ +function getOrg(org, callback) { + // build the request options + var options = getOptions(API_BASE + '/organizations/search/findByName?startsWith=' + org + '&ignoreCase=false', + auth.getToken(), 'GET'); + + // do the request + request(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback, []); + return; + } else if ( err ) { + callback(new Error(util.format('Getting org failed: %s', err)), []); + return; + } else if ( res.statusCode >= 400 ) { + callback(new Error(util.format('Getting org failed: %s', res.statusCode))); + return; + } else if ( body.content.length === 0 ) { + callback(new Error(util.format('Unknown org %s', org))); + return; + } else if ( body.content.length > 1 ) { + // attempt to find an exact match + var filtered = body.content.filter(function(cand) { + // check on filter criterias + return ( cand.name === org ); + }); + if ( filtered.length === 1 ) { + callback(undefined, filterOrg(filtered[0])); + return; + } + // report ambiguousness + callback(new Error(util.format('Org %s is ambiguous', org))); + return; + } + // do the callback with the body + callback(undefined, filterOrg(body.content[0])); + }); +} + +/** + * Filters properties of the passed org and returns a reduced object containing only + * an allowed list of properties. + * + * @param {Object} org the original org object + * @return {Object} the filtered org + */ +function filterOrg(org) { + for (var prop in org) { + if (org.hasOwnProperty(prop) && ORG_ALLOWED_READ_PROPERTIES.indexOf(prop) === -1) { + // delete the property if not allowed to read + delete org[prop]; + } + } + return org; +} + +/** + * Retrieves all orgs and returns them as list. + * + * @param {Function} callback the callback to execute, the error and the list of orgs are available as arguments to the callback function + */ +function getOrgs(callback) { + + // build the request options + var options = getOptions(API_BASE + '/organizations', auth.getToken(), 'GET'); + + // do the request + request(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback, []); + return; + } else if ( err ) { + callback(new Error(util.format('Searching orgs failed: %s', err)), []); + return; + } else if ( res.statusCode >= 400 ) { + callback(new Error(util.format('Searching orgs failed: %s', res.statusCode))); + return; + } + callback(undefined, body.content); + }); +} + +module.exports.getOrg = getOrg; +module.exports.cli = { + /** + * Lists all org eligible to manage + * + * @param {String} orgId the org id or null, if all orgs should be retrieved + * @param {Boolean} asJson optional flag to force output in json, false by default + * @param {String} sortBy optional field to sort the list of users by + */ + list : function(orgId, asJson, sortBy) { + // get details of a single org if org was passed + if ( typeof(orgId) !== 'undefined' && orgId !== null ) { + getOrg(orgId, function(err, org) { + if (err) { + console.error(err.message); + return; + } + if (asJson) { + console.json(org); + return; + } + + console.prettyPrint(org); + }); + return; + } + // get all orgs + getOrgs(function(err, list) { + if (err) { + console.error(err.message); + return; + } + + if (sortBy) { + list = require('./json').sort(list, sortBy); + } + + if (asJson) { + console.json(list); + return; + } + + if (list.length === 0) { + console.info('No orgs found'); + return; + } + + // table fields + var data = [['id', 'name','realms','twoFARoles']]; + for (var i of list) { + data.push([i.id, i.name, i.realms.length, ( i.twoFARoles.length > 0 )]); + } + + console.table(data); + }); + } +}; \ No newline at end of file diff --git a/lib/role.js b/lib/role.js new file mode 100644 index 00000000..2299cf28 --- /dev/null +++ b/lib/role.js @@ -0,0 +1,323 @@ +var request = require('request'); +var util = require('util'); + +var auth = require('./auth'); +var console = require('./log'); +var ocapi = require('./ocapi'); +var libUser = require('./user'); + +const API_BASE = '/dw/rest/v1'; +const ROLE_LIST_PAGE_SIZE = 25; +const ROLE_ALLOWED_READ_PROPERTIES = [ 'id', 'description', 'permissions', 'user_count', 'user_manager', 'users' ]; +const USER_ALLOWED_READ_PROPERTIES = [ 'disabled', 'email', 'first_name', 'last_name', 'login' ]; + +/** + * Helper to capture most-common responses due to errors which occur across resources. In case a well-known issue + * was identified, the function returns an Error object holding detailed information about the error. A callback + * function can be passed optionally, the error and the response are passed as parameters to the callback function. + * + * @param {Object} err + * @param {Object} response + * @param {Function} callback + * @return {Error} the error or null + */ +function captureCommonErrors(err, response, callback) { + var error = null; + if (err && !response) { + error = new Error('The operation could not be performed properly. ' + ( process.env.DEBUG ? err : '' )); + } else if (response.body && response.body.errors && response.body.errors[0] && + response.body.errors[0].code === 'AccessDeniedException') { + error = new Error('Unsufficient privileges'); + } else if (response.statusCode === 401) { + error = new Error('Authentication invalid. Please (re-)authenticate by running ' + + '´sfcc-ci auth:login´ or ´sfcc-ci client:auth´'); + } else if (response.body && response.body.fault && + response.body.fault.type === 'ClientAccessForbiddenException') { + error = new Error('Insufficient permissions. Ensure your API key has permissions ' + + 'to perform this operation on the instance.'); + } else if (response.statusCode >= 400 && response.body && response.body.fault) { + error = new Error(response.body.fault.message); + } + // just return the error, in case no callback is passed + if (!callback) { + return error; + } + callback(error, response); +} + +/** + * Contructs the http request options and ensure shared request headers across requests, such as authentication. + * + * @param {String} path + * @param {String} token + * @param {String} method + * @return {Object} the request options + */ +function getOptions(path, token, method) { + var opts = { + uri: 'https://' + auth.getAMHost() + path, + auth: { + bearer: ( token ? token : null ) + }, + strictSSL: false, + method: method, + json: true + }; + return opts; +} + +/** + * Retrieves roles + * + * @param {Function} callback the callback to execute, the error, the response body and the list of roles are available as arguments to the callback function + */ +function searchRoles(count, callback) { + // the page size + var size = ROLE_LIST_PAGE_SIZE + if ( count ) { + size = Number.parseInt(count); + } + + var endpoint = '/roles?page=0&size=' + size; + + // build the request options + var options = getOptions(API_BASE + endpoint, auth.getToken(), 'GET'); + + // do the request + request(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback, []); + return; + } else if ( err ) { + callback(new Error(util.format('Searching roles failed: %s', err)), []); + return; + } else if ( res.statusCode >= 400 ) { + callback(new Error(util.format('Searching roles failed: %s', res.statusCode))); + return; + } + callback(undefined, body, body.content); + }); +} + +/** + * Retrieves detals of a role + * + * @param {String} instance the instance to get the role from + * @param {String} role the role to get details for + * @param {Boolean} verbose enable verbose role details, false by default + * @param {Function} callback the callback to execute, the error and the role are available as arguments to the callback function + */ +function getRole(instance, role, verbose, callback) { + // build the request options + var endpoint = '/s/-/dw/data/' + ocapi.getOcapiVersion() + '/roles/' + role + + '?select=(**)&expand=users,permissions'; + var options = ocapi.getOptions(instance, endpoint, auth.getToken(), 'GET'); + + // do the request + request(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback); + return; + } else if ( err ) { + callback(new Error(util.format('Getting role failed: %s', err))); + return; + } else if ( res.statusCode === 404 ) { + callback(new Error(util.format('Role %s not found', role))); + return; + } else if ( res.statusCode >= 400 ) { + callback(new Error(util.format('Getting role failed: %s', res.statusCode))); + return; + } + + // do the callback with the body + callback(undefined, filterLocalRole(body, verbose)); + }); +} + +/** + * Retrieves roles from an instance + * + * @param {Number} instance the instance to search roles for + * @param {String} query the query to search role for + * @param {String} sortBy the field to sort role by + * @param {String} count optional number of items per page + * @param {Function} callback the callback to execute, the error and the list of role are available as arguments to the callback function + */ +function searchLocalRoles(instance, query, sortBy, count, callback) { + // the page size + var size = ROLE_LIST_PAGE_SIZE + if ( count ) { + size = Number.parseInt(count); + } + + // build the request options + var endpoint = '/s/-/dw/data/' + ocapi.getOcapiVersion() + '/role_search'; + var options = ocapi.getOptions(instance, endpoint, auth.getToken(), 'POST'); + + // default query, match all + var q = { + match_all_query : {} + }; + // use a user provided query + if ( query ) { + q = query; + } + + // the payload + options['body'] = { + count : size, + query : q, + select : '(**)' + }; + + // apply sorting + if ( sortBy ) { + options['body']['sorts'] = [{ + field : sortBy + }]; + } + + // do the request + request(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback); + return; + } else if ( err ) { + callback(new Error(util.format('Searching roles failed: %s', err))); + return; + } else if ( res.statusCode >= 400 ) { + callback(new Error(util.format('Searching roles failed: %s', res.statusCode))); + return; + } + callback(undefined, body, body.hits); + }); +} + +/** + * Filters properties of the passed role object and returns a reduced object containing only + * an allowed list of properties. + * + * @param {Object} role the original role object + * @param {Boolean} verbose enable additional details at the role, false by default + * @return {Object} the filtered role + */ +function filterLocalRole(role, verbose) { + if (!verbose) { + delete role['permissions']; + delete role['users']; + } + for (var prop in role) { + if (role.hasOwnProperty(prop) && ROLE_ALLOWED_READ_PROPERTIES.indexOf(prop) === -1) { + // delete the property if not allowed to read + delete role[prop]; + } else if (role.hasOwnProperty(prop) && prop === 'users') { + role[prop] = role[prop].map(libUser.filterLocalUser); + } + } + return role; +} + +module.exports.cli = { + /** + * Lists all roles + * + * @param {Boolean} asJson optional flag to force output in json, false by default + */ + list : function(count, asJson) { + // get all roles + searchRoles(count, function(err, roleResult, list) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + if (asJson) { + console.json(roleResult); + return; + } + + if (roleResult.page.totalElements === 0) { + console.info('No roles found'); + return; + } + + // table fields + var data = [['id','description','roleEnumName']]; + for (var i of list) { + data.push([i.id, i.description, i.roleEnumName]); + } + + console.table(data); + }); + }, + + /** + * Lists all roles + * + * @param {String} instance the instance to list roles for + * @param {String} role the role to get details for (optional) + * @param {String} query the query to search users for + * @param {String} role the role to search users for + * @param {String} sortBy optional number of items per page + * @param {Boolean} asJson optional flag to force output in json, false by default + * @param {Boolean} verbose optional flag to show more details, false by default + */ + listLocal : function(instance, role, query, role, sortBy, count, asJson, verbose) { + // get details of a single user if login was passed + if ( typeof(role) !== 'undefined' && role !== null ) { + getRole(instance, role, verbose, function(err, role) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + if (asJson) { + console.json(role); + return; + } + + console.prettyPrint(role); + }); + return; + } + // get all roles + searchLocalRoles(instance, query, sortBy, count, function(err, roleResult, list) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + if (asJson) { + console.json(roleResult); + return; + } + + if (roleResult.total === 0) { + console.info('No roles found'); + return; + } + + // table fields + var data = [['id','user_count','user_manager']]; + for (var i of list) { + data.push([i.id, i.user_count, i.user_manager]); + } + + console.table(data); + }); + } +}; \ No newline at end of file diff --git a/lib/user.js b/lib/user.js new file mode 100644 index 00000000..ecf1ce5c --- /dev/null +++ b/lib/user.js @@ -0,0 +1,1570 @@ +var request = require('request'); +var util = require('util'); + +var auth = require('./auth'); +var console = require('./log'); +var ocapi = require('./ocapi'); +var libOrg = require('./org'); + +const API_BASE = '/dw/rest/v1'; +const USER_LIST_PAGE_SIZE = 25; +const USER_ALLOWED_READ_PROPERTIES = [ 'id', 'userState', 'roles', 'roleTenantFilter', 'primaryOrganization', 'mail', + 'firstName', 'lastName', 'displayName', 'organizations' ]; +const ORG_ALLOWED_READ_PROPERTIES = [ 'id', 'name', 'realms', 'twoFARoles' ]; +const ROLE_NAMES_MAP = { 'bm-admin' : 'ECOM_ADMIN', 'bm-user' : 'ECOM_USER' }; +const ROLE_NAMES_MAP_REVERSE = { 'ECOM_ADMIN' : 'bm-admin', 'ECOM_USER' : 'bm-user' }; + +/** + * Maps the role name to an internal role ID accepted by the API. + * + * @param {String} role the role name to map + * @return {String} the internal role ID + */ +function mapToInternalRole(role) { + if ( typeof(ROLE_NAMES_MAP[role]) !== 'undefined' ) { + return ROLE_NAMES_MAP[role]; + } + return role.toUpperCase().replace(/\-/g,'_'); +} + +/** + * Maps the internal role ID to role name. + * + * @param {String} roleID the role ID to map + * @return {String} the role name + */ +function mapFromInternalRole(roleID) { + if ( typeof(ROLE_NAMES_MAP_REVERSE[roleID]) !== 'undefined' ) { + return ROLE_NAMES_MAP_REVERSE[roleID]; + } + return roleID.toLowerCase().replace(/\_/g,'-'); +} + +/** + * Helper to capture most-common responses due to errors which occur across resources. In case a well-known issue + * was identified, the function returns an Error object holding detailed information about the error. A callback + * function can be passed optionally, the error and the response are passed as parameters to the callback function. + * + * @param {Object} err + * @param {Object} response + * @param {Function} callback + * @return {Error} the error or null + */ +function captureCommonErrors(err, response, callback) { + var error = null; + if (err && !response) { + error = new Error('The operation could not be performed properly. ' + ( process.env.DEBUG ? err : '' )); + } else if (response['body'] && response['body']['errors'] && response['body']['errors'][0] && + response['body']['errors'][0]['code'] === 'AccessDeniedException') { + error = new Error('Unsufficient privileges'); + } else if (response.statusCode === 401) { + error = new Error('Authentication invalid. Please (re-)authenticate by running ' + + '´sfcc-ci auth:login´ or ´sfcc-ci client:auth´'); + } else if (response['body'] && response['body']['fault'] && + response['body']['fault']['type'] === 'ClientAccessForbiddenException') { + error = new Error('Insufficient permissions. Ensure your API key has permissions ' + + 'to perform this operation on the instance.'); + } else if (response.statusCode >= 400 && response['body'] && response['body']['fault']) { + error = new Error(response['body']['fault']['message']); + } + // just return the error, in case no callback is passed + if (!callback) { + return error; + } + callback(error, response); +} + +/** + * Contructs the http request options and ensure shared request headers across requests, such as authentication. + * + * @param {String} path + * @param {String} token + * @param {String} method + * @return {Object} the request options + */ +function getOptions(path, token, method) { + var opts = { + uri: 'https://' + auth.getAMHost() + path, + auth: { + bearer: ( token ? token : null ) + }, + strictSSL: false, + method: method, + json: true + }; + return opts; +} + +/** + * Retrieves details of a user. + * + * @param {String} login the login of the user + * @param {Function} callback the callback to execute, the error and the user are available as arguments to the callback function + */ +function getUser(login, callback) { + // build the request options + var options = getOptions(API_BASE + '/users/search/findByLogin/?login=' + login, auth.getToken(), 'GET'); + + // do the request + request(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback); + return; + } else if ( res.statusCode === 404 ) { + callback(new Error(util.format('User %s not found', login))); + return; + } else if ( res.statusCode >= 400 ) { + callback(new Error(util.format('Getting user failed: %s', res.statusCode))); + return; + } else if ( err ) { + callback(new Error(util.format('Getting user failed: %s', err))); + return; + } + + // do the callback with the body + // filter some properties before returning + callback(undefined, filterUser(body)); + }); +} + +/** + * Filters properties of the passed user object and returns a reduced object containing only + * an allowed list of properties. + * + * @param {Object} user the original user object + * @return {Object} the filtered role + */ +function filterLocalUser(user) { + for (var prop in user) { + if (user.hasOwnProperty(prop) && USER_ALLOWED_READ_PROPERTIES.indexOf(prop) === -1) { + // delete the property if not allowed to read + delete user[prop]; + } + } + return user; +} + +/** + * Filters properties of the passed user object and returns a reduced object containing only + * an allowed list of properties. + * + * @param {Object} user the original user object + * @return {Object} the filtered user + */ +function filterUser(user) { + for (var prop in user) { + if (user.hasOwnProperty(prop) && USER_ALLOWED_READ_PROPERTIES.indexOf(prop) === -1) { + // delete the property if not allowed to read + delete user[prop]; + } else if ( prop === 'roleTenantFilter' && user[prop] !== null ) { + // transform to object form + var scopeFilters = {}; + var groups = user[prop].split(';'); + for (var i=0; i= 400 ) { + callback(new Error(util.format('Searching users failed: %s', res.statusCode))); + return; + } + callback(undefined, body, body['content']); + }); +} + +/** + * Creates a new user in the passed org. + * + * @param {String} orgID the org id to create the user in + * @param {Object} user the user details + * @param {Function} callback the callback to execute, the error and the created user are available as arguments to the callback function + */ +function createUser(orgID, user, callback) { + // build the request options + var options = getOptions(API_BASE + '/users', auth.getToken(), 'POST'); + + // update with default roles + if (typeof(user['roles']) === 'undefined') { + user['roles'] = [ "xchange-user", "doc-user" ]; + } + + // merge the passed user object with some hard coded properties + var mergedUser = Object.assign(user, { + displayName : [ user['firstName'], user['lastName'] ].join(' '), + organizations : [ orgID ], + primaryOrganization : orgID, + userState : "ENABLED" + }); + + // the payload + options['body'] = mergedUser; + + // do the request + request.post(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback); + return; + } else if (res.statusCode >= 400 && body['errors']) { + callback(new Error(util.format('Creating user failed: %s (%s)', + body['errors'][0]['message'], body['errors'][0]['fieldErrors'][0]['defaultMessage']))); + return; + } else if (res.statusCode >= 400) { + callback(new Error(util.format('Creating user failed: %s', res.statusCode))); + return; + } else if (err) { + callback(new Error(util.format('Creating user failed: %s', err))); + return; + } + // do the callback with the body + // filter some properties before returning + callback(errback, filterUser(body)); + }); +} + +/** + * Updates an existing user. + * + * @param {Object} user the user to update + * @param {Object} changes the changes to make to the user + * @param {Function} callback the callback to execute, the error and the updated user are available as arguments to the callback function + */ +function updateUser(user, changes, callback) { + // build the request options + var options = getOptions(API_BASE + '/users/' + user['id'], auth.getToken(), 'PUT'); + + // merge changes with user + var updatedUser = Object.assign(user, changes); + + console.debug("Changes: %s", JSON.stringify(changes)); + console.debug("Patched user: %s", JSON.stringify(updatedUser)); + + // the payload, transform into internal payload (API properties and internal JSON structure) + options['body'] = toInternalUser(updatedUser); + + console.debug("Patched internal user: %s", JSON.stringify(options['body'])); + + // do the request + request.put(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + + console.debug(body); + + if ( errback ) { + callback(errback); + return; + } else if (err) { + callback(new Error(util.format('Updating user failed: %s', err))); + return; + } else if (res.statusCode >= 400 && body['errors']) { + callback(new Error(util.format('Updating user failed: %s', body['errors'][0]['message']))); + return; + } else if (res.statusCode >= 400) { + callback(new Error(util.format('Updating user failed: %s', res.statusCode))); + return; + } + + // do the callback with the body + // filter some properties before returning + callback(errback, filterUser(body)); + }); +} + +/** + * Deletes an existing user. The deletion happens in Account Manager. The user is only marked as deleted. + * In order to purge the user completely use the purge flag. + * + * @param {Object} user the user to delete + * @param {Boolean} purge flag, whether to purge the user completely, false by default + * @param {Function} callback the callback to execute, the error and the user are available as arguments to the callback function + */ +function deleteUser(user, purge, callback) { + // for purging we fire a full DELETE request + if (purge) { + // build the request options + var options = getOptions(API_BASE + '/users/' + user['id'], auth.getToken(), 'DELETE'); + + // do the request + request.delete(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + + console.debug(body); + + if ( errback ) { + callback(errback); + return; + } else if (err) { + callback(new Error(util.format('Deleting user failed: %s', err))); + return; + } else if (res.statusCode >= 400 && body && body['errors']) { + callback(new Error(util.format('Deleting user failed: %s', body['errors'][0]['message']))); + return; + } else if (res.statusCode >= 400) { + callback(new Error(util.format('Deleting user failed: %s', res.statusCode))); + return; + } + + // do the callback + callback(errback); + }); + } else { + // for deletion, just mark the user as deleted via userState + // build the request options + var options = getOptions(API_BASE + '/users/' + user['id'], auth.getToken(), 'PUT'); + + // modify the userState to DELETED + var updatedUser = user; + updatedUser['userState'] = 'DELETED'; + + console.debug("Patched user: %s", JSON.stringify(updatedUser)); + + // the payload, transform into internal payload (API properties and internal JSON structure) + options['body'] = toInternalUser(updatedUser); + + console.debug("Patched internal user: %s", JSON.stringify(options['body'])); + + // do the request + request.put(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + + console.debug(body); + + if ( errback ) { + callback(errback); + return; + } else if (err) { + callback(new Error(util.format('Deleting user failed: %s', err))); + return; + } else if (res.statusCode >= 400 && body['errors']) { + callback(new Error(util.format('Deleting user failed: %s', body['errors'][0]['message']))); + return; + } else if (res.statusCode >= 400) { + callback(new Error(util.format('Deleting user failed: %s', res.statusCode))); + return; + } + + // do the callback with the body + // filter some properties before returning + callback(errback, filterUser(body)); + }); + } +} + +/** + * Creates a new local user in the passed instance. + * + * @param {String} instance the instance to create the user in + * @param {String} login the login of the user + * @param {Object} user the user details + * @param {Function} callback the callback to execute, the error and the created user are available as arguments to the callback function + */ +function createLocalUser(instance, login, user, callback) { + // error, in case password has been provided + // the user has to set his password via link in activation email sent by the instance + if ( user['password'] ) { + callback(new Error(util.format('Creating user %s failed: Providing a user password is not allowed', login))); + return; + } + + // build the request options + var endpoint = '/s/-/dw/data/' + ocapi.getOcapiVersion() + '/users/' + login; + + // build the request options + var options = ocapi.getOptions(instance, endpoint, auth.getToken(), 'PUT'); + + // merge the passed user object with some hard coded properties + var mergedUser = Object.assign(user, { + login : login + }); + + // the payload + options['body'] = mergedUser; + + // do the request + request(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback, []); + return; + } else if ( err ) { + callback(new Error(util.format('Creating user %s failed: %s', login, err))); + return; + } else if (res.statusCode >= 400) { + callback(new Error(util.format('Creating user %s failed: %s', login, res.statusCode))); + return; + } + // do the callback with the body + // filter some properties before returning + callback(undefined, filterLocalUser(body)); + }); +} + +/** + * Retrieves details of a local user. + * + * @param {String} login the login of the local user + * @param {Function} callback the callback to execute, the error and the user are available as arguments to the callback function + */ +function getLocalUser(instance, login, callback) { + // build the request options + var endpoint = '/s/-/dw/data/' + ocapi.getOcapiVersion() + '/users/' + login; + + // build the request options + var options = ocapi.getOptions(instance, endpoint, auth.getToken(), 'GET'); + + // do the request + request(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback); + return; + } else if ( res.statusCode === 404 ) { + callback(new Error(util.format('User %s not found on %s', login, instance))); + return; + } else if ( res.statusCode >= 400 ) { + callback(new Error(util.format('Getting user failed: %s', res.statusCode))); + return; + } else if ( err ) { + callback(new Error(util.format('Getting user failed: %s', err))); + return; + } + + // do the callback with the body + // filter some properties before returning + callback(undefined, body); + }); +} + +/** + * Updates an existing local user in the passed instance. + * + * @param {String} instance the instance to update the user on + * @param {Object} user the local user to update + * @param {Object} changes the changes to apply to the user + * @param {Function} callback the callback to execute, the error and the updated user are available as arguments + */ +function updateLocalUser(instance, user, changes, callback) { + // build the request options + var endpoint = '/s/-/dw/data/' + ocapi.getOcapiVersion() + '/users/' + user['login']; + + // build the request options + var options = ocapi.getOptions(instance, endpoint, auth.getToken(), 'PATCH'); + + // add resource_state required for deletion + options['headers'] = { + 'x-dw-resource-state' : user['_resource_state'] + }; + + // the payload + options['body'] = changes; + + // do the request + request(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback, []); + return; + } else if ( err ) { + callback(new Error(util.format('Updating user %s failed: %s', login, err))); + return; + } else if (res.statusCode >= 400) { + callback(new Error(util.format('Updating user %s failed: %s', login, res.statusCode))); + return; + } + // do the callback with the body + // filter some properties before returning + callback(undefined, filterLocalUser(body)); + }); +} + +/** + * Grant a role to a local user on the passed instance + * + * @param {String} instance instance to grant the user the role on + * @param {String} login the login of the user + * @param {String} role the role to grant + * @param {Function} callback the callback to execute, the error and the changed user are available as arguments to the callback function + */ +function grantLocalRole(instance, login, role, callback) { + // build the request options + var endpoint = '/s/-/dw/data/' + ocapi.getOcapiVersion() + '/roles/' + role + '/users/' + login; + var options = ocapi.getOptions(instance, endpoint, auth.getToken(), 'PUT'); + + // do the request + request(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback); + return; + } else if ( err ) { + callback(new Error(util.format('Granting user %s role %s failed: %s', login, role, err))); + return; + } else if (res.statusCode >= 400) { + callback(new Error(util.format('Creating user %s role %s failed: %s', login, role, res.statusCode))); + return; + } + // do the callback with the body + callback(undefined, body); + }); +} + +/** + * Revoke a role from a local user on the passed instance + * + * @param {String} instance instance to revoke the user the role from + * @param {String} login the login of the user + * @param {String} role the role to revoke + * @param {Function} callback the callback to execute, the error and the changed user are available as arguments to the callback function + */ +function revokeLocalRole(instance, login, role, callback) { + // build the request options + var endpoint = '/s/-/dw/data/' + ocapi.getOcapiVersion() + '/roles/' + role + '/users/' + login; + var options = ocapi.getOptions(instance, endpoint, auth.getToken(), 'DELETE'); + + // do the request + request(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback); + return; + } else if ( err ) { + callback(new Error(util.format('Revoking role %s from user %s failed: %s', login, role, err))); + return; + } else if (res.statusCode >= 400) { + callback(new Error(util.format('Revoking role %s from user %s failed: %s', login, role, res.statusCode))); + return; + } + // do the callback with the body + callback(undefined, body); + }); +} + +/** + * Delete a local user from the passed instance. + * + * @param {String} instance the instance to create the user in + * @param {String} login the login of the user + * @param {String} state resource state before deletion + * @param {Function} callback the callback to execute, the error is available as argument to the callback function + */ +function deleteLocalUser(instance, login, state, callback) { + // build the request options + var endpoint = '/s/-/dw/data/' + ocapi.getOcapiVersion() + '/users/' + login; + + // build the request options + var options = ocapi.getOptions(instance, endpoint, auth.getToken(), 'DELETE'); + + // add resource_state required for deletion + if ( state ) { + options['headers'] = { + 'x-dw-resource-state' : state + }; + } + + // do the request + request(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback, []); + return; + } else if ( err ) { + callback(new Error(util.format('Deleting the user %s failed: %s', login, err))); + return; + } else if (res.statusCode >= 400) { + callback(new Error(util.format('Deleting the user %s failed: %s', login, res.statusCode))); + return; + } + // do the callback without error + callback(undefined); + }); +} + +/** + * Search local users on an instance + * + * @param {String} instance the instance to search users on + * @param {String} query the query to search users for + * @param {String} role the role to search users for + * @param {String} sortBy the field to sort users by + * @param {String} count optional number of items per page + * @param {String} start zero-based index of the first search hit to include + * @param {Boolean} callback the callback to execute, the error, the result and the list of users of the current page are available as arguments to the callback function + */ +function searchLocalUsers(instance, query, role, sortBy, count, start, callback) { + // the page size + var size = USER_LIST_PAGE_SIZE + if ( count ) { + size = Number.parseInt(count); + } + + // the item index + var index = 0 + if ( start ) { + index = Number.parseInt(start); + } + + // build the request options + var endpoint = '/s/-/dw/data/' + ocapi.getOcapiVersion() + '/user_search'; + if ( role ) { + endpoint = '/s/-/dw/data/' + ocapi.getOcapiVersion() + '/roles/' + role + '/user_search'; + } + + // build the request options + var options = ocapi.getOptions(instance, endpoint, auth.getToken(), 'POST'); + + // default query, match all + var q = { + match_all_query : {} + }; + // use a user provided query + if ( query ) { + q = query; + } + + // the payload + options['body'] = { + count : size, + query : q, + select : '(**)', + start : index + }; + + // apply sorting + if ( sortBy ) { + options['body']['sorts'] = [{ + field : sortBy + }]; + } + + // in order to support processing larger amounts of users, we fall back to GET /users endpoint + // with no querying or sorting + if ( !query && !role && !sortBy && count > 200 ) { + // modify the request options + var options = ocapi.getOptions(instance, '/s/-/dw/data/' + ocapi.getOcapiVersion() + '/users?count=' + + size + '&select=(**)', auth.getToken(), 'GET'); + } + + // do the request + request(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + if ( errback ) { + callback(errback); + return; + } else if ( err ) { + callback(new Error(util.format('Searching users failed: %s', err))); + return; + } else if ( res.statusCode >= 400 ) { + callback(new Error(util.format('Searching users failed: %s', res.statusCode))); + return; + } + callback(undefined, body, body['hits'] || body['data']); + }); +} + +/** + * Grant an existing AM user a new role, or extend an already granted role with a scope. + * + * @param {Object} user the user to grant the role for + * @param {String} role the role to grant + * @param {String} scope the optional scope of the role + * @param {Function} callback the callback to execute, the error and the changed user are available as arguments to the callback function + */ +function grantRole(user, role, scope, callback) { + // build the request options + var options = getOptions(API_BASE + '/users/' + user['id'], auth.getToken(), 'PUT'); + + console.debug("Existing user: %s", JSON.stringify(user)); + + // merge the user object with new role / scope + var mergedUser = user; + if ( user['roles'] && user['roles'].indexOf(role) === -1 ) { + mergedUser['roles'] = user['roles'].concat([role]); + } + + // merge the user object with expanded scope + if ( scope ) { + var scopes = scope.split(','); + + if ( user['roleTenantFilter'] && typeof(user['roleTenantFilter'][role]) !== 'undefined' ) { + // expand scope of existing role + mergedUser['roleTenantFilter'] = user['roleTenantFilter']; + mergedUser['roleTenantFilter'][role] = mergedUser['roleTenantFilter'][role].concat(scopes); + } else if ( user['roleTenantFilter'] && typeof(user['roleTenantFilter'][role]) === 'undefined' ) { + // add scope for new role + mergedUser['roleTenantFilter'] = user['roleTenantFilter']; + mergedUser['roleTenantFilter'][role] = scopes; + } else if ( !typeof(user['roleTenantFilter']) ) { + // create new tenant filter map + mergedUser['roleTenantFilter'] = { + role : scopes + } + } + } + + console.debug("Patched user: %s", JSON.stringify(mergedUser)); + + // the payload, transform into internal payload (API properties and internal JSON structure) + options['body'] = toInternalUser(mergedUser); + + console.debug("Patched internal user: %s", JSON.stringify(options['body'])); + + // do the request + request.put(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + + console.debug(body); + + if ( errback ) { + callback(errback); + return; + } else if (err) { + callback(new Error(util.format('Granting role failed: %s', err))); + return; + } else if (res.statusCode >= 400 && body['errors']) { + callback(new Error(util.format('Granting role failed: %s', body['errors'][0]['message']))); + return; + } else if (res.statusCode >= 400) { + callback(new Error(util.format('Granting role failed: %s', res.statusCode))); + return; + } + + // do the callback with the body + // filter some properties before returning + callback(errback, filterUser(body)); + }); +} + +/** + * Revoke a role from an existing AM user, or reduce the scope of a granted role. + * + * @param {Object} user the user to revoke the role from + * @param {String} role the role to revoke + * @param {String} scope the optional scope of the role to reduce + * @param {Function} callback the callback to execute, the error and the changed user are available as arguments to the callback function + */ +function revokeRole(user, role, scope, callback) { + // build the request options + var options = getOptions(API_BASE + '/users/' + user['id'], auth.getToken(), 'PUT'); + + console.debug("Existing user: %s", JSON.stringify(user)); + + // remove the complete role (incl. all scopes if used) + var mergedUser = user; + if ( user['roles'] && user['roles'].indexOf(role) !== -1 && !scope ) { + var roleIndex = user['roles'].indexOf(role); + + // remove the role + mergedUser['roles'].splice(roleIndex, 1); + // remove all scopes + if (user['roleTenantFilter'] && typeof(mergedUser['roleTenantFilter'][role]) !== 'undefined') { + delete mergedUser['roleTenantFilter'][role]; + } + } + + // reduce only by passed scope, but leave role and other scopes + if ( scope ) { + var scopes = scope.split(','); + + if ( user['roleTenantFilter'] && typeof(user['roleTenantFilter'][role]) !== 'undefined' ) { + for (var i=0; i -1 ) { + mergedUser['roleTenantFilter'][role].splice(scopeIndex, 1); + } + } + // if zero scope left, remove completely from map + if ( mergedUser['roleTenantFilter'][role].length === 0 ) { + delete mergedUser['roleTenantFilter'][role]; + } + } + } + + console.debug("Patched user: %s", JSON.stringify(mergedUser)); + + // the payload, transform into internal payload (API properties and internal JSON structure) + options['body'] = toInternalUser(mergedUser); + + console.debug("Patched internal user: %s", JSON.stringify(options['body'])); + + // do the request + request.put(options, function (err, res, body) { + var errback = captureCommonErrors(err, res); + + console.debug(body); + + if ( errback ) { + callback(errback); + return; + } else if (err) { + callback(new Error(util.format('Revoking role failed: %s', err))); + return; + } else if (res.statusCode >= 400 && body['errors']) { + callback(new Error(util.format('Revoking role failed: %s', body['errors'][0]['message']))); + return; + } else if (res.statusCode >= 400) { + callback(new Error(util.format('Revoking role failed: %s', res.statusCode))); + return; + } + + // do the callback with the body + // filter some properties before returning + callback(errback, filterUser(body)); + }); +} + +/** + * Convenience function to print user access roles + * + * @param {Array} roles an array of roles to print + */ +function prettyPrintRoles(roles) { + if (!roles || roles.length == 0) { + return; + } else if (roles.indexOf('Administrator') !== -1 && roles.length >= 2) { + return 'Administrator (+' + roles.length + ' more)'; + } else if (roles.indexOf('Administrator') !== -1) { + return 'Administrator'; + } else { + return roles[0] + ( roles.length > 1 ? ' (+' + roles.length + ' more)' : '' ); + } +}; + +module.exports.filterLocalUser = filterLocalUser; +module.exports.cli = { + /** + * Creates a new user. + * + * @param {String} org the org to create the user in + * @param {Object} user the user details + * @param {String} mail the login (email) of the new user (must be unique) + * @param {String} firstName the first name + * @param {String} lastName the last name + * @param {Boolean} asJson optional flag to force output in json, false by default + */ + create : function(org, user, mail, firstName, lastName, asJson) { + // respect user, login, firstName and lastName (if passed) + if (typeof(user) === 'undefined' || user === null) { + user = {}; + } + if (typeof(mail) !== 'undefined' && mail !== null) { + user['mail'] = mail; + } + if (typeof(firstName) !== 'undefined' && firstName !== null) { + user['firstName'] = firstName; + } + if (typeof(lastName) !== 'undefined' && lastName !== null) { + user['lastName'] = lastName; + } + + libOrg.getOrg(org, function(err, foundOrg) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + createUser(foundOrg['id'], user, function(err, newUser) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + // the result + var result = { + message : util.format('New user %s in org %s created.', newUser.mail, org), + }; + + if (asJson) { + console.json(newUser); + return; + } + + console.info(result['message']); + }); + }); + }, + + /** + * Lists all users eligible to manage + * + * @param {String} org the org or null, if all users should be retrieved + * @param {String} role the role or null, if all users should be retrieved + * @param {String} login the login or null, if all users should be retrieved + * @param {Number} count the max count of list items + * @param {Boolean} asJson optional flag to force output in json, false by default + * @param {String} sortBy optional field to sort the list of users by + */ + list : function(org, role, login, count, asJson, sortBy) { + // get details of a single user if login was passed + if ( typeof(login) !== 'undefined' && login !== null ) { + getUser(login, function(err, user) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + if (asJson) { + console.json(user); + return; + } + + console.prettyPrint(user); + }); + return; + } + // get users + // define the callback + var getUsersCallback = function(err, result, list) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + if (sortBy) { + list = require('./json').sort(list, sortBy); + } + + if (asJson) { + // if sorted, then only provide the list of the current page + if (sortBy) { + console.json(list); + } else { + console.json(result); + } + return; + } + + if (list.length === 0) { + console.info('No users found'); + return; + } + + // table fields + var data = [['mail','firstName','lastName','userState']]; + for (var i of list) { + data.push([i.mail, i.firstName, i.lastName, i.userState]); + } + + console.table(data); + }; + + // in case org was passed, resolve org uuid + if ( org ) { + libOrg.getOrg(org, function(err, foundOrg) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + getUsers(foundOrg['id'], role, count, getUsersCallback); + }); + return; + } + // no org was passed + getUsers(null, role, count, getUsersCallback); + }, + + /** + * Update a user. + * + * @param {String} login login of the user to update + * @param {Object} changes changes to the user details + * @param {Boolean} asJson optional flag to force output in json, false by default + */ + update : function(login, changes, asJson) { + getUser(login, function(err, user) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + updateUser(user, changes, function(err, updatedUser) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + // the result + var result = { + message : util.format('User %s has been updated.', login), + }; + + if (asJson) { + console.json(updatedUser); + return; + } + + console.info(result['message']); + }); + }); + }, + + /** + * Grant a role to a user + * + * @param {String} login the login (email) of the user + * @param {String} role the role to grant + * @param {String} scope the scope of the role to grant + * @param {Boolean} asJson optional flag to force output in json, false by default + */ + grant : function(login, role, scope, asJson) { + if (typeof(login) === 'undefined' || login === null) { + console.error("Missing login. Please pass a login using -l,--login"); + return; + } + if (typeof(role) === 'undefined' || role === null) { + console.error("Missing role. Please pass a role using -r,--role"); + return; + } + getUser(login, function(err, user) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + grantRole(user, role, scope, function(err, changedUser) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + // the result + var result = { + message : util.format('User %s granted role %s.', login, role), + }; + + if (scope) { + result['message'] = util.format('User %s granted role %s with scope %s.', login, role, scope); + } + + if (asJson) { + console.json(changedUser); + return; + } + + console.info(result['message']); + }); + }); + }, + + /** + * Revoke a role from a user + * + * @param {String} login the login (email) of the user + * @param {String} role the role to revoke + * @param {String} scope the scope of the role to revoke + * @param {Boolean} asJson optional flag to force output in json, false by default + */ + revoke : function(login, role, scope, asJson) { + if (typeof(login) === 'undefined' || login === null) { + console.error("Missing login. Please pass a login using -l,--login"); + return; + } + if (typeof(role) === 'undefined' || role === null) { + console.error("Missing role. Please pass a role using -r,--role"); + return; + } + getUser(login, function(err, user) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + revokeRole(user, role, scope, function(err, changedUser) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + // the result + var result = { + message : util.format('User %s revoked role %s.', login, role), + }; + + if (scope) { + result['message'] = util.format('User %s revoked role %s with scope %s.', login, role, scope); + } + + if (asJson) { + console.json(changedUser); + return; + } + + console.info(result['message']); + }); + }); + }, + + /** + * Delete a user. + * + * @param {Object} login the user to delete + * @param {Boolean} purge whether to purge the user completely + * @param {Boolean} asJson optional flag to force output in json, false by default + */ + delete : function(login, purge, asJson) { + getUser(login, function(err, user) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + deleteUser(user, purge, function(err) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + // the result + var result = { + message : util.format('User %s %s.', login, ( purge ? 'purged' : 'deleted' )), + }; + + if (asJson) { + console.json(result); + return; + } + + console.info(result['message']); + }); + }); + }, + + /** + * Creates a new local user on an instance. + * + * @param {String} instance the instance to create the user on + * @param {String} login the login of the user + * @param {Object} user the user details + * @param {Boolean} asJson optional flag to force output in json, false by default + */ + createLocal : function(instance, login, user, asJson) { + if (typeof(user) === 'undefined' || user === null) { + console.error("Missing user details. Please pass details using -u,--user"); + return; + } + if (typeof(login) === 'undefined' || login === null) { + console.error("Missing login. Please pass a login using -l,--login"); + return; + } + createLocalUser(instance, login, user, function(err, newUser) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + // the result + var result = { + message : util.format('New user %s in instance %s created.', newUser.login, instance), + }; + + if (asJson) { + console.json(newUser); + return; + } + + console.info(result['message']); + }); + }, + + /** + * Search for local users on an instance + * + * @param {String} instance the instance to search users on + * @param {String} login the login or null, if all users should be retrieved + * @param {String} query the query to search users for + * @param {String} role the role to search users for + * @param {String} sortBy optional field to sort users by + * @param {String} count optional number of items per page + * @param {String} start optional zero-based index of the first search hit to include + * @param {Boolean} asJson optional flag to force output in json, false by default + */ + searchLocal : function(instance, login, query, role, sortBy, count, start, asJson) { + // get details of a single user if login was passed + if ( typeof(login) !== 'undefined' && login !== null ) { + getLocalUser(instance, login, function(err, user) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + if (asJson) { + console.json(user); + return; + } + + console.prettyPrint(user); + }); + return; + } + // get all users + searchLocalUsers(instance, query, role, sortBy, count, start, function(err, result, list) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + if (asJson) { + // silently make list of users contained in property data available via property hits + if (typeof(result['hits']) == 'undefined' && typeof(result['data']) != 'undefined' ) { + result['hits'] = result['data']; + } + console.json(result); + return; + } + + if (result.total === 0) { + console.info('No users found'); + return; + } + + // table fields + var data = [['login','email','first_name','last_name','disabled', 'external_id', 'roles']]; + for (var i of list) { + data.push([i.login, i.email, i.first_name, i.last_name, i.disabled, i.external_id, + prettyPrintRoles(i.roles)]); + } + + console.table(data); + }); + }, + + /** + * Update a local user. + * + * @param {String} instance instance to grant the user the role on + * @param {String} login login of the local user to update + * @param {Object} changes changes to the user details + * @param {Boolean} asJson optional flag to force output in json, false by default + */ + updateLocal : function(instance, login, changes, asJson) { + if (typeof(instance) === 'undefined' || instance === null) { + console.error('Missing instance. Please pass an instance using -i,--instance'); + return; + } + if (typeof(login) === 'undefined' || login === null) { + console.error('Missing login. Please pass a login using -l,--login'); + return; + } + getLocalUser(instance, login, function(err, user) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + updateLocalUser(instance, user, changes, function(err, updatedUser) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + // the result + var result = { + message : util.format('User %s has been updated on %s.', login, instance), + }; + + if (asJson) { + console.json(updatedUser); + return; + } + + console.info(result['message']); + }); + }); + }, + + /** + * Grant a role to a local user + * + * @param {String} instance instance to grant the user the role on + * @param {String} login the login of the user + * @param {String} role the role to grant + * @param {Boolean} asJson optional flag to force output in json, false by default + */ + grantLocal : function(instance, login, role, asJson) { + if (typeof(instance) === 'undefined' || instance === null) { + console.error('Missing instance. Please pass an instance using -i,--instance'); + return; + } + if (typeof(login) === 'undefined' || login === null) { + console.error('Missing login. Please pass a login using -l,--login'); + return; + } + if (typeof(role) === 'undefined' || role === null) { + console.error('Missing role. Please pass a role using -r,--role'); + return; + } + getLocalUser(instance, login, function(err, user) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + grantLocalRole(instance, login, role, function(err, changedUser) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + // the result + var result = { + message : util.format('Granted role %s to user %s on %s', role, login, instance), + }; + + if (asJson) { + console.json(changedUser); + return; + } + + console.info(result['message']); + }); + }); + }, + + /** + * Revoke a role from a local user + * + * @param {String} instance instance to revoke the user the role from + * @param {String} login the login of the user + * @param {String} role the role to revoke + * @param {Boolean} asJson optional flag to force output in json, false by default + */ + revokeLocal : function(instance, login, role, asJson) { + if (typeof(instance) === 'undefined' || instance === null) { + console.error('Missing instance. Please pass an instance using -i,--instance'); + return; + } + if (typeof(login) === 'undefined' || login === null) { + console.error('Missing login. Please pass a login using -l,--login'); + return; + } + if (typeof(role) === 'undefined' || role === null) { + console.error('Missing role. Please pass a role using -r,--role'); + return; + } + getLocalUser(instance, login, function(err, user) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + revokeLocalRole(instance, login, role, function(err, changedUser) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + // the result + var result = { + message : util.format('Revoked role %s from user %s on %s.', role, login, instance), + }; + + if (asJson) { + console.json(changedUser); + return; + } + + console.info(result['message']); + }); + }); + }, + + /** + * Delete a local user. + * + * @param {String} instance instance to delete the user from + * @param {Object} login the user to delete + * @param {Boolean} asJson optional flag to force output in json, false by default + */ + deleteLocal : function(instance, login, asJson) { + getLocalUser(instance, login, function(err, user) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + deleteLocalUser(instance, login, user['_resource_state'], function(err) { + if (err) { + if (asJson) { + console.json({error: err.message}); + } else { + console.error(err.message); + } + return; + } + + // the result + var result = { + message : util.format('User %s deleted from %s.', login, instance), + }; + + if (asJson) { + console.json(result); + return; + } + + console.info(result['message']); + }); + }); + } +}; \ No newline at end of file