@axway/amplify-sdk
Version:
Axway Amplify SDK for Node.js
1,499 lines (1,327 loc) • 66.7 kB
JavaScript
import Auth from './auth.js';
import crypto from 'crypto';
import errors from './errors.js';
import fs from 'fs-extra';
import open from 'open';
import path, { dirname } from 'path';
import Server from './server.js';
import setCookie from 'set-cookie-parser';
import snooplogg from 'snooplogg';
import { r as resolve } from './environments-C3ppEMBw.js';
import * as request from '@axway/amplify-request';
import { createURL } from './util.js';
import { promisify } from 'util';
import { redact } from '@axway/amplify-utils';
import { fileURLToPath } from 'url';
import './authenticators/authenticator.js';
import 'ejs';
import './endpoints.js';
import 'jws';
import './stores/token-store.js';
import 'pluralize';
import './authenticators/client-secret.js';
import './authenticators/owner-password.js';
import './authenticators/pkce.js';
import './authenticators/signed-jwt.js';
import 'fs';
import 'uuid';
import './stores/file-store.js';
import './stores/memory-store.js';
import './stores/secure-store.js';
import 'keytar';
import 'get-port';
import 'http';
/* eslint-disable promise/no-nesting */
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const { error, log, warn } = snooplogg('amplify-sdk');
const { highlight, note } = snooplogg.styles;
/**
* An SDK for accessing Amplify API's.
*/
class AmplifySDK {
/**
* Initializes the environment and SDK's API.
*
* @param {Object} opts - Authentication options.
* @param {Object} [opts.env=prod] - The environment name.
* @param {Object} [opts.requestOptions] - HTTP request options with proxy settings and such to
* create a `got` HTTP client.
* @access public
*/
constructor(opts = {}) {
if (typeof opts !== 'object') {
throw errors.INVALID_ARGUMENT('Expected options to be an object');
}
/**
* Authentication options including baseURL, clientID, env, realm, and token store settings.
* @type {Object}
*/
this.opts = { ...opts };
delete this.opts.username;
delete this.opts.password;
/**
* Resolved environment-specific settings.
* @type {Object}
*/
this.env = resolve(opts.env);
// set the defaults based on the environment
for (const prop of [ 'baseUrl', 'platformUrl', 'realm' ]) {
if (!opts[prop]) {
opts[prop] = this.env[prop];
}
}
/**
* The `got` HTTP client.
* @type {Function}
*/
this.got = request.init(opts.requestOptions);
if (!opts.got) {
opts.got = this.got;
}
/**
* The base Axway ID URL.
* @type {String}
*/
this.baseUrl = opts.baseUrl ? opts.baseUrl.replace(/\/$/, '') : null;
/**
* The platform URL.
* @type {String}
*/
this.platformUrl = opts.platformUrl ? opts.platformUrl.replace(/\/$/, '') : null;
/**
* The Axway ID realm.
* @type {String}
*/
this.realm = opts.realm;
const { version } = fs.readJsonSync(path.resolve(__dirname, '../package.json'));
/**
* The Axway ID realm.
*
* IMPORTANT! Platform explicitly checks this user agent, so do NOT change the name or case.
*
* @type {String}
*/
this.userAgent = `AMPLIFY SDK/${version} (${process.platform}; ${process.arch}; node:${process.versions.node})${process.env.AXWAY_CLI ? ` Axway CLI/${process.env.AXWAY_CLI}` : ''}`;
this.auth = {
/**
* Finds an authenticated account or `null` if not found. If the account is found and
* the access token is expired yet the refresh token is still valid, it will
* automatically get a valid access token.
* @param {String} accountName - The name of the account including the client id prefix.
* @param {Object} [defaultTeams] - A map of account hashes to their selected team guid.
* @returns {Promise<Object>} Resolves the account info object.
*/
find: async (accountName, defaultTeams) => {
const account = await this.authClient.find(accountName);
return account ? await this.auth.loadSession(account, defaultTeams) : null;
},
/**
* Retrieves platform session information such as the organizations, then mutates the
* account object and returns it.
* @param {Object} account - The account object.
* @param {Object} [defaultTeams] - A map of account hashes to their selected team guid.
* @returns {Promise<Object>} Resolves the original account info object.
*/
findSession: async (account, defaultTeams) => {
if (defaultTeams && typeof defaultTeams !== 'object') {
throw errors.INVALID_ARGUMENT('Expected default teams to be an object');
}
const result = await this.request('/api/v1/auth/findSession', account, {
errorMsg: 'Failed to find session'
});
account.isPlatform = !!result;
const populateOrgs = (org, orgs) => {
account.org = {
entitlements: Object
.entries(org.entitlements || {})
.reduce((obj, [ name, value ]) => {
if (name[0] !== '_') {
obj[name] = value;
}
return obj;
}, {}),
guid: org.guid,
id: org.org_id,
name: org.name,
region: org.region,
subscriptions: org.subscriptions || [],
teams: []
};
account.orgs = orgs.map(({ guid, name, org_id }) => ({
default: org_id === org.org_id,
guid,
id: org_id,
name
}));
};
if (result) {
const { org, orgs, role, roles, user } = result;
populateOrgs(org, orgs);
account.role = role;
account.roles = roles;
account.user = Object.assign({}, account.user, {
axwayId: user.axway_id,
dateJoined: user.date_activated,
email: user.email,
firstname: user.firstname,
guid: user.guid,
lastname: user.lastname,
organization: user.organization,
phone: user.phone
});
} else if (account.org?.id) {
const org = await this.org.find(account, account.org.guid);
org.org_id = org.org_id || org.id;
populateOrgs(org, [ org ]);
}
account.team = undefined;
if (account.user.guid) {
const { teams } = await this.team.list(account, account.org?.id, account.user.guid);
account.org.teams = teams;
const selectedTeamGuid = defaultTeams?.[account.hash];
if (teams.length) {
const team = teams.find(t => (selectedTeamGuid && t.guid === selectedTeamGuid) || (!selectedTeamGuid && t.default)) || teams[0];
account.team = {
default: team.default,
guid: team.guid,
name: team.name,
roles: account.user.guid && team.users?.find(u => u.guid === account.user.guid)?.roles || [],
tags: team.tags
};
}
}
return account;
},
/**
* Returns a list of all authenticated accounts.
* @param {Object} [opts] - Various options.
* @param {Object} [opts.defaultTeams] - A map of account hashes to their selected team guid.
* @param {Array.<String>} [opts.skip] - A list of accounts to skip validation for.
* @param {Boolean} [opts.validate] - When `true`, checks to see if each account has an
* active access token and session.
* @returns {Promise<Array>}
*/
list: async (opts = {}) => {
if (!opts || typeof opts !== 'object') {
throw errors.INVALID_ARGUMENT('Expected options to be an object');
}
return this.authClient.list()
.then(accounts => accounts.reduce((promise, account) => {
return promise.then(async list => {
if (opts.validate && (!opts.skip || !opts.skip.includes(account.name))) {
try {
account = await this.auth.find(account.name, opts.defaultTeams);
} catch (err) {
warn(`Failed to load session for account "${account.name}": ${err.toString()}`);
}
}
if (account?.auth) {
delete account.auth.clientSecret;
delete account.auth.password;
delete account.auth.secret;
delete account.auth.username;
list.push(account);
}
return list;
});
}, Promise.resolve([])))
.then(list => list.sort((a, b) => a.name.localeCompare(b.name)));
},
/**
* Populates the specified account info object with a dashboard session id and org
* information.
* @param {Object} account - The account object.
* @param {Object} [defaultTeams] - A map of account hashes to their selected team guid.
* @returns {Promise<Object>} Resolves the original account info object.
*/
loadSession: async (account, defaultTeams) => {
try {
// grab the org guid before findSession clobbers it
const { guid } = account.org;
account = await this.auth.findSession(account, defaultTeams);
// validate the service account
if (account.isPlatformTooling) {
const filteredOrgs = account.orgs.filter(o => o.guid === guid);
if (!filteredOrgs.length) {
error(`Service account belongs to org "${guid}", but platform account belongs to:\n${account.orgs.map(o => ` "${o.guid}"`).join('\n')}`);
throw new Error('The service account\'s organization does not match the specified platform account\'s organizations');
}
account.orgs = filteredOrgs;
}
} catch (err) {
if (err.code === 'ERR_SESSION_INVALIDATED') {
warn(`Detected invalidated session, purging account ${highlight(account.name)}`);
await this.authClient.logout({
accounts: [ account.name ],
baseUrl: this.baseUrl
});
return null;
}
throw err;
}
await this.authClient.updateAccount(account);
if (account.isPlatform) {
log(`Current org: ${highlight(account.org.name)} ${note(`(${account.org.guid})`)}`);
log('Available orgs:');
for (const org of account.orgs) {
log(` ${highlight(org.name)} ${note(`(${org.guid})`)}`);
}
}
delete account.auth.clientSecret;
delete account.auth.password;
delete account.auth.secret;
delete account.auth.username;
return account;
},
/**
* Authenticates a user, retrieves the access tokens, populates the session id and
* org info, and returns it.
* @param {Object} opts - Various authentication options to override the defaults set
* via the `Auth` constructor.
* @param {String} [opts.password] - The platform tooling password used to
* authenticate. Requires a `username` and `clientSecret` or `secretFile`.
* @param {String} [opts.secretFile] - The path to the PEM formatted private key used
* to sign the JWT.
* @param {String} [opts.username] - The platform tooling username used to
* authenticate. Requires a `password` and `clientSecret` or `secretFile`.
* @returns {Promise<Object>} Resolves the account info object.
*/
login: async (opts = {}) => {
let account;
// validate the username/password
const { password, username } = opts;
if (username || password) {
const clientSecret = opts.clientSecret || this.opts.clientSecret;
const secretFile = opts.secretFile || this.opts.secretFile;
if (!clientSecret && !secretFile) {
throw new Error('Username/password can only be specified when using client secret or secret file');
}
if (!username || typeof username !== 'string') {
throw new TypeError('Expected username to be an email address');
}
if (!password || typeof password !== 'string') {
throw new TypeError('Expected password to be a non-empty string');
}
delete opts.username;
delete opts.password;
}
// check if already logged in
if (!opts?.force) {
account = await this.authClient.find(opts);
if (account && !account.auth.expired) {
warn(`Account ${highlight(account.name)} is already authenticated`);
const err = new Error('Account already authenticated');
err.account = account;
try {
err.account = await this.auth.loadSession(account);
} catch (e) {
warn(e);
}
err.code = 'EAUTHENTICATED';
throw err;
}
}
// do the login
account = await this.authClient.login(opts);
// if we're in manual mode (e.g. --no-launch-browser), then return now
if (opts.manual) {
// account is actually an object containing `cancel`, `promise`, and `url`
return account;
}
try {
// upgrade the service account with the platform tooling account
if (username && password) {
// request() will set the sid and update the account in the token store
await this.request('/api/v1/auth/login', account, {
errorMsg: 'Failed to authenticate',
isToolingAuth: true,
json: {
from: 'cli',
username,
password
}
});
account.isPlatformTooling = true;
}
return await this.auth.loadSession(account);
} catch (err) {
// something happened, revoke the access tokens we just got and rethrow
await this.authClient.logout({
accounts: [ account.name ],
baseUrl: this.baseUrl
});
throw err;
}
},
/**
* Discards an access token and notifies AxwayID to revoke the access token.
* @param {Object} opts - Various authentication options to override the defaults set
* via the `Auth` constructor.
* @param {Array.<String>} opts.accounts - A list of accounts names.
* @param {Boolean} opts.all - When `true`, revokes all accounts.
* @param {String} [opts.baseUrl] - The base URL used to filter accounts.
* @param {Function} [opts.onOpenBrowser] - A callback when the web browser is about to
* be launched.
* @returns {Promise<Object>} Resolves a list of revoked credentials.
*/
logout: async ({ accounts, all, baseUrl = this.baseUrl } = {}) => {
if (all) {
accounts = await this.authClient.list();
} else {
if (!Array.isArray(accounts)) {
throw errors.INVALID_ARGUMENT('Expected accounts to be a list of accounts');
}
if (!accounts.length) {
return [];
}
accounts = (await this.authClient.list()).filter(account => accounts.includes(account.name));
}
for (const account of accounts) {
if (account.isPlatform && !account.isPlatformTooling) {
// note: there should only be 1 platform account in the accounts list
const { platformUrl } = resolve(account.auth.env);
const redirect = `${platformUrl}/signed.out?msg=signout`;
const url = `${platformUrl}/auth/signout?redirect=${encodeURIComponent(redirect)}`;
if (typeof opts.onOpenBrowser === 'function') {
await opts.onOpenBrowser({ url });
}
try {
await open(url);
} catch (err) {
const m = err.message.match(/Exited with code (\d+)/i);
throw m ? new Error(`Failed to open web browser (code ${m[1]})`) : err;
}
}
}
return await this.authClient.logout({ accounts: accounts.map(account => account.hash), baseUrl });
},
/**
* Returns AxwayID server information.
* @param {Object} opts - Various authentication options to override the defaults set
* via the `Auth` constructor.
* @returns {Promise<object>}
*/
serverInfo: opts => this.authClient.serverInfo(opts),
/**
* Switches your current organization.
* @param {Object} [account] - The account object. Note that this object reference will
* be updated with new org info.
* @param {Object|String|Number} [org] - The organization object, name, guid, or id.
* @param {Object} [opts] - Various options.
* @param {Function} [opts.onOpenBrowser] - A callback when the web browser is about to
* be launched.
* @returns {Promise<Object>} Resolves the updated account object.
*/
switchOrg: async (account, org, opts = {}) => {
if (!account || account.auth.expired) {
log(`${account ? 'Account is expired' : 'No account specified'}, doing login`);
account = await this.authClient.login();
} else {
try {
org = this.resolvePlatformOrg(account, org);
} catch (err) {
if (err.code !== 'ERR_INVALID_ACCOUNT' && err.code !== 'ERR_INVALID_PLATFORM_ACCOUNT' && err.code !== 'ERR_INVALID_ARGUMENT') {
// probably org not found
throw err;
}
org = undefined;
}
log(`Switching ${highlight(account.name)} to org ${highlight(org.name)} (${org.guid})`);
const server = new Server();
const { start, url: redirect } = await server.createCallback((req, res) => {
log(`Telling browser to redirect to ${highlight(this.platformUrl)}`);
res.writeHead(302, {
Location: this.platformUrl
});
res.end();
});
try {
const url = createURL(`${this.platformUrl}/#/auth/org.select`, {
org_hint: org?.id,
redirect
});
log(`Launching default web browser: ${highlight(url)}`);
if (typeof opts.onOpenBrowser === 'function') {
await opts.onOpenBrowser({ url });
}
try {
await open(url);
} catch (err) {
const m = err.message.match(/Exited with code (\d+)/i);
throw m ? new Error(`Failed to open web browser (code ${m[1]})`) : err;
}
log(`Waiting for browser to be redirected to: ${highlight(redirect)}`);
await start();
const authenticator = this.authClient.createAuthenticator(this.authClient.applyDefaults());
await new Promise(resolve => setTimeout(resolve, 3000));
account = await authenticator.getToken(undefined, undefined, true);
} finally {
await server.stop();
}
}
try {
log('Refreshing the account session...');
if (account.sid) {
log(`Deleting sid ${account.sid}`);
delete account.sid;
}
return await this.auth.loadSession(account);
} catch (e) {
// squelch
log(e);
}
throw new Error('Failed to switch organization');
},
async timeout() {
return new Promise(resolve => setTimeout(resolve, 3000));
}
};
this.client = {
/**
* Creates a new service account.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {Object} opts - Various options.
* @param {String} [opts.desc] - The service account description.
* @param {String} opts.name - The display name.
* @param {String} [opts.publicKey] - A PEM formatted public key.
* @param {Array<String>} [opts.roles] - A list of roles to assign to the service account.
* @param {String} [opts.secret] - A client secret key.
* @param {Array<Object>} [opts.teams] - A list of objects containing `guid` and `roles`
* properties.
* @returns {Promise<Object>}
*/
create: async (account, org, opts = {}) => {
org = this.resolvePlatformOrg(account, org);
if (!opts || typeof opts !== 'object') {
throw errors.INVALID_ARGUMENT('Expected options to be an object');
}
if (!opts.name || typeof opts.name !== 'string') {
throw errors.INVALID_ARGUMENT('Expected name to be a non-empty string');
}
if (opts.desc && typeof opts.desc !== 'string') {
throw errors.INVALID_ARGUMENT('Expected description to be a string');
}
const data = {
name: opts.name,
description: opts.desc || '',
org_guid: org.guid
};
if (opts.publicKey) {
if (typeof opts.publicKey !== 'string') {
throw errors.INVALID_ARGUMENT('Expected public key to be a string');
}
if (!opts.publicKey.startsWith('-----BEGIN PUBLIC KEY-----')) {
throw new Error('Expected public key to be PEM formatted');
}
data.type = 'certificate';
data.publicKey = opts.publicKey;
} else if (opts.secret) {
if (typeof opts.secret !== 'string') {
throw errors.INVALID_ARGUMENT('Expected secret to be a string');
}
data.type = 'secret';
data.secret = opts.secret;
} else {
throw new Error('Expected public key or secret');
}
if (opts.roles) {
data.roles = await this.role.resolve(account, opts.roles, { client: true, org });
}
if (opts.teams) {
data.teams = await this.client.resolveTeams(account, org, opts.teams);
}
return {
org,
client: await this.request('/api/v1/client', account, {
errorMsg: 'Failed to create service account',
json: data
})
};
},
/**
* Finds a service account by client id.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {String} clientId - The service account's client id.
* @returns {Promise<Object>}
*/
find: async (account, org, clientId) => {
assertPlatformAccount(account);
const { clients } = await this.client.list(account, org);
// first try to find the service account by guid, then client id, then name
let client = clients.find(c => c.guid === clientId);
if (!client) {
client = clients.find(c => c.client_id === clientId);
}
if (!client) {
client = clients.find(c => c.name === clientId);
}
// if still not found, error
if (!client) {
throw new Error(`Service account "${clientId}" not found`);
}
// get service account description
const { description } = await this.request(`/api/v1/client/${client.client_id}`, account, {
errorMsg: 'Failed to get service account'
});
client.description = description;
const { teams } = await this.team.list(account, client.org_guid);
client.teams = [];
for (const team of teams) {
const user = team.users.find(u => u.type === 'client' && u.guid === client.guid);
if (user) {
client.teams.push({
...team,
roles: user.roles
});
}
}
return {
org: await this.org.find(account, client.org_guid),
client
};
},
/**
* Generates a new public/private key pair.
* @returns {Promise<Object>} Resolves an object with `publicKey` and `privateKey` properties.
*/
async generateKeyPair() {
return await promisify(crypto.generateKeyPair)('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
},
/**
* Retrieves a list of all service accounts for the given org.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @returns {Promise<Object>} Resolves the service account that was removed.
*/
list: async (account, org) => {
org = this.resolvePlatformOrg(account, org);
const clients = await this.request(`/api/v1/client?org_id=${org.id}`, account, {
errorMsg: 'Failed to get service accounts'
});
return {
org,
clients: clients
.map(c => {
c.method = this.client.resolveType(c.type);
return c;
})
.sort((a, b) => a.name.localeCompare(b.name))
};
},
/**
* Removes a service account.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {Object|String} client - The service account object or client id.
* @returns {Promise<Object>} Resolves the service account that was removed.
*/
remove: async (account, org, client) => {
client = await this.client.resolveClient(account, org, client);
await this.request(`/api/v1/client/${client.client_id}`, account, {
errorMsg: 'Failed to remove service account',
method: 'delete'
});
return { client, org };
},
/**
* Resolves an org by name, id, org guid using the specified account.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, guid, or id.
* @param {Object|String} client - The service account object or client id.
* @returns {Promise<Object>}
*/
resolveClient: async (account, org, client) => {
if (client && typeof client === 'object' && client.client_id) {
return client;
}
if (client && typeof client === 'string') {
return (await this.client.find(account, org, client)).client;
}
throw errors.INVALID_ARGUMENT('Expected client to be an object or client id');
},
/**
* Validates a list of teams for the given org.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {Array<Object>} [teams] - A list of objects containing `guid` and `roles`
* properties.
* @returns {Array<String>} An aray of team guids.
*/
resolveTeams: async (account, org, teams) => {
if (!Array.isArray(teams)) {
throw errors.INVALID_ARGUMENT('Expected teams to be an array');
}
if (!teams.length) {
return;
}
const { teams: availableTeams } = await this.team.list(account, org);
const teamRoles = await this.role.list(account, { team: true, org });
const guids = {};
const resolvedTeams = [];
for (const team of teams) {
if (!team || typeof team !== 'object' || !team.guid || typeof team.guid !== 'string' || !team.roles || !Array.isArray(team.roles) || !team.roles.length) {
throw errors.INVALID_ARGUMENT('Expected team to be an object containing a guid and array of roles');
}
// find the team by name or guid
const lt = team.guid.toLowerCase().trim();
const found = availableTeams.find(t => t.guid === lt || t.name.toLowerCase() === lt);
if (!found) {
throw new Error(`Invalid team "${team.guid}"`);
}
// validate roles
for (const role of team.roles) {
if (!teamRoles.find(r => r.id === role)) {
throw new Error(`Invalid team role "${role}"`);
}
}
// dedupe
if (guids[found.guid]) {
continue;
}
guids[found.guid] = 1;
resolvedTeams.push({
guid: found.guid,
roles: team.roles
});
}
return resolvedTeams;
},
/**
* Returns the service account auth type label.
* @param {String} type - The auth type.
* @returns {String}
*/
resolveType(type) {
return type === 'secret' ? 'Client Secret' : type === 'certificate' ? 'Client Certificate' : 'Other';
},
/**
* Updates an existing service account's information.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {Object} opts - Various options.
* @param {Object|String} opts.client - The service account object or client id.
* @param {String} [opts.desc] - The service account description.
* @param {String} [opts.name] - The display name.
* @param {String} [opts.publicKey] - A PEM formatted public key.
* @param {Array<String>} [opts.roles] - A list of roles to assign to the service account.
* @param {String} [opts.secret] - A client secret key.
* @param {Array<Object>} [opts.teams] - A list of objects containing `guid` and `roles`
* properties.
* @returns {Promise}
*/
update: async (account, org, opts = {}) => {
org = this.resolvePlatformOrg(account, org);
if (!opts || typeof opts !== 'object') {
throw errors.INVALID_ARGUMENT('Expected options to be an object');
}
const client = await this.client.resolveClient(account, org, opts.client);
const data = {};
if (opts.name) {
if (typeof opts.name !== 'string') {
throw errors.INVALID_ARGUMENT('Expected name to be a non-empty string');
}
data.name = opts.name;
}
if (opts.desc) {
if (typeof opts.desc !== 'string') {
throw errors.INVALID_ARGUMENT('Expected description to be a string');
}
data.description = opts.desc;
}
if (opts.publicKey) {
if (typeof opts.publicKey !== 'string') {
throw errors.INVALID_ARGUMENT('Expected public key to be a string');
}
if (!opts.publicKey.startsWith('-----BEGIN PUBLIC KEY-----')) {
throw new Error('Expected public key to be PEM formatted');
}
if (client.type !== 'certificate') {
throw new Error(`Service account "${client.name}" uses auth method "${this.client.resolveType(client.type)}" and cannot be changed to "${this.client.resolveType('certificate')}"`);
}
data.publicKey = opts.publicKey;
} else if (opts.secret) {
if (typeof opts.secret !== 'string') {
throw errors.INVALID_ARGUMENT('Expected secret to be a string');
}
if (client.type !== 'secret') {
throw new Error(`Service account "${client.name}" uses auth method "${this.client.resolveType(client.type)}" and cannot be changed to "${this.client.resolveType('secret')}"`);
}
data.secret = opts.secret;
}
if (opts.roles !== undefined) {
data.roles = !opts.roles ? [] : await this.role.resolve(account, opts.roles, { client: true, org });
}
if (opts.teams !== undefined) {
data.teams = opts.teams && await this.client.resolveTeams(account, org, opts.teams) || [];
}
return {
org,
client: await this.request(`/api/v1/client/${client.guid}`, account, {
errorMsg: 'Failed to update service account',
json: data,
method: 'put'
})
};
}
};
this.entitlement = {
/**
* Retrieves entitlement information for a specific entitlement metric.
* @param {Object} account - The account object.
* @param {String} metric - The entitlement metric name.
* @returns {Promise<Object>}
*/
find: (account, metric) => this.request(`/api/v1/entitlement/${metric}`, account, {
errorMsg: 'Failed to get entitlement info'
})
};
/**
* Retrieves activity for an organization or user.
* @param {Object} account - The account object.
* @param {Object} [params] - Various parameters.
* @param {String} [params.from] - The start date in ISO format.
* @param {String|Boolean} [params.month] - A month date range. Overrides `to` and `from`.
* If `true`, uses current month.
* @param {Object|String|Number} [params.org] - The organization object, name, guid, or id.
* @param {String} [params.to] - The end date in ISO format.
* @param {String} [params.userGuid] - The user guid.
* @returns {Promise<Object>}
*/
const getActivity = async (account, params = {}) => {
assertPlatformAccount(account);
if (params.month !== undefined) {
Object.assign(params, resovleMonthRange(params.month));
}
let { from, to } = resolveDateRange(params.from, params.to);
let url = '/api/v1/activity?data=true';
if (params.org) {
const { id } = this.resolvePlatformOrg(account, params.org);
url += `&org_id=${id}`;
}
if (params.userGuid) {
url += `&user_guid=${params.userGuid}`;
}
if (from) {
url += `&from=${from.toISOString()}`;
}
if (to) {
url += `&to=${to.toISOString()}`;
}
return {
from,
to,
events: await this.request(url, account, {
errorMsg: 'Failed to get user activity'
})
};
};
this.org = {
/**
* Retieves organization activity.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, guid, or id.
* @param {Object} [params] - Various parameters.
* @param {String} [params.from] - The start date in ISO format.
* @param {String|Boolean} [params.month] - A month date range. Overrides `to` and
* `from`. If `true`, uses current month.
* @param {String} [params.to] - The end date in ISO format.
* @returns {Promise<Object>}
*/
activity: (account, org, params) => getActivity(account, {
...params,
org
}),
/**
* Retrieves the list of environments associated to the user's org.
* @param {Object} account - The account object.
* @returns {Promise<Array>}
*/
environments: async account => {
assertPlatformAccount(account);
return await this.request('/api/v1/org/env', account, {
errorMsg: 'Failed to get organization environments'
});
},
/**
* Retrieves the organization family used to determine the child orgs.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @returns {Promise<Object>}
*/
family: async (account, org) => {
const { id } = this.resolvePlatformOrg(account, org);
return await this.request(`/api/v1/org/${id}/family`, account, {
errorMsg: 'Failed to get organization family'
});
},
/**
* Retrieves organization details for an account.
* @param {Object} account - The account object.
* @param {String} org - The organization object, name, id, or guid.
* @returns {Promise<Array>}
*/
find: async (account, org) => {
const { id } = this.resolveOrg(account, org);
org = await this.request(`/api/v1/org/${id}`, account, {
errorMsg: 'Failed to get organization'
});
const subscriptions = org.subscriptions.map(s => ({
category: s.product, // TODO: Replace with annotated name
edition: s.plan, // TODO: Replace with annotated name
expired: !!s.expired,
governance: s.governance || 'SaaS',
startDate: s.start_date,
endDate: s.end_date,
tier: s.tier
}));
const { teams } = await this.team.list(account, id);
const result = {
active: org.active,
created: org.created,
childOrgs: null, // deprecated
guid: org.guid,
id: id,
name: org.name,
entitlements: org.entitlements,
parentOrg: null, // deprecated
region: org.region,
insightUserCount: ~~org.entitlements.limit_read_only_users,
seats: org.entitlements.limit_users === 10000 ? null : org.entitlements.limit_users,
subscriptions,
teams,
teamCount: teams.length,
userCount: org.users.length,
userRoles: org.users.find(u => u.guid === account.user.guid)?.roles || []
};
if (org.entitlements?.partners) {
for (const partner of org.entitlements.partners) {
result[partner] = org[partner];
}
}
return result;
},
/**
* Retrieves the list of orgs from the specified account.
* @param {Object} account - The account object.
* @param {String} defaultOrg - The name, id, or guid of the default organization.
* @returns {Promise<Array>}
*/
list: async (account, defaultOrg) => {
assertPlatformAccount(account);
const { guid } = this.resolvePlatformOrg(account, defaultOrg);
return account.orgs.map(o => ({
...o,
default: o.guid === guid
})).sort((a, b) => a.name.localeCompare(b.name));
},
user: {
/**
* Adds a user to an org.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {String} email - The user's email.
* @param {Array.<String>} roles - One or more roles to assign. Must include a "default" role.
* @returns {Promise<Object>}
*/
add: async (account, org, email, roles) => {
org = this.resolvePlatformOrg(account, org);
const { guid } = await this.request(`/api/v1/org/${org.id}/user`, account, {
errorMsg: 'Failed to add user to organization',
json: {
email,
roles: await this.role.resolve(account, roles, { org, requireDefaultRole: true })
}
});
log(`User "${guid}" added to org ${org.name} (${org.guid})`);
return {
org,
user: await this.org.user.find(account, org, guid)
};
},
/**
* Finds a user and returns their information.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {String} user - The user email or guid.
* @returns {Promise<Object>}
*/
find: async (account, org, user) => {
const { users } = await this.org.user.list(account, org);
user = user.toLowerCase();
return users.find(m => String(m.email).toLowerCase() === user || String(m.guid).toLowerCase() === user);
},
/**
* Lists all users in an org.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @returns {Promise<Object>}
*/
list: async (account, org) => {
org = this.resolvePlatformOrg(account, org);
const users = await this.request(`/api/v1/org/${org.id}/user?clients=1`, account, {
errorMsg: 'Failed to get organization users'
});
return {
org,
users: users.sort((a, b) => {
if ((a.client_id && !b.client_id) || (!a.client_id && b.client_id)) {
return !a.client_id ? -1 : a.client_id ? 1 : 0;
}
const aname = a.name || `${a.firstname} ${a.lastname}`.trim();
const bname = b.name || `${b.firstname} ${b.lastname}`.trim();
return aname.localeCompare(bname);
})
};
},
/**
* Removes an user from an org.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {String} user - The user email or guid.
* @returns {Promise<Object>}
*/
remove: async (account, org, user) => {
org = this.resolvePlatformOrg(account, org);
const found = await this.org.user.find(account, org.guid, user);
if (!found) {
throw new Error(`Unable to find the user "${user}"`);
}
return {
org,
user: found,
...(await this.request(`/api/v1/org/${org.id}/user/${found.guid}`, account, {
errorMsg: 'Failed to remove user from organization',
method: 'delete'
}))
};
},
/**
* Updates a users role in an org.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {String} user - The user email or guid.
* @param {Array.<String>} roles - One or more roles to assign. Must include a "default" role.
* @returns {Promise<Object>}
*/
update: async (account, org, user, roles) => {
org = this.resolvePlatformOrg(account, org);
const found = await this.org.user.find(account, org.guid, user);
if (!found) {
throw new Error(`Unable to find the user "${user}"`);
}
roles = await this.role.resolve(account, roles, { org, requireDefaultRole: true });
return {
org: await this.request(`/api/v1/org/${org.id}/user/${found.guid}`, account, {
errorMsg: 'Failed to update user\'s organization roles',
json: {
roles
},
method: 'put'
}),
roles,
user: await this.org.user.find(account, org, found.guid)
};
}
},
/**
* Renames an org.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {String} name - The new organization name.
* @returns {Promise<Object>}
*/
rename: async (account, org, name) => {
const { id, name: oldName } = this.resolvePlatformOrg(account, org);
if (typeof name !== 'string' || !(name = name.trim())) {
throw errors.INVALID_ARGUMENT('Organization name must be a non-empty string');
}
return {
...(await this.request(`/api/v1/org/${id}`, account, {
errorMsg: 'Failed to rename organization',
json: { name },
method: 'put'
})),
oldName
};
},
/**
* Renames an org.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {Object} [params] - Various parameters.
* @param {String} [params.from] - The start date in ISO format.
* @param {String|Boolean} [params.month] - A month date range. Overrides `to` and
* `from`. If `true`, uses current month.
* @param {String} [params.to] - The end date in ISO format.
* @returns {Promise<Object>}
*/
usage: async (account, org, params = {}) => {
const { id } = this.resolvePlatformOrg(account, org);
if (params.month !== undefined) {
Object.assign(params, resovleMonthRange(params.month));
}
const { from, to } = resolveDateRange(params.from, params.to);
let url = `/api/v1/org/${id}/usage`;
if (from) {
url += `?from=${from.toISOString()}`;
}
if (to) {
url += `${from ? '&' : '?'}to=${to.toISOString()}`;
}
const results = await this.request(url, account, {
errorMsg: 'Failed to get organization usage'
});
if (results.bundle?.metrics) {
for (const [ metric, info ] of Object.entries(results.bundle.metrics)) {
if (!info.name) {
info.name = (await this.entitlement.find(account, metric)).title;
}
}
}
return {
...results,
from,
to
};
}
};
this.role = {
/**
* Get all roles.
* @param {Object} account - The account object.
* @param {Object} [params] - Various parameters.
* @param {Boolean} [params.client] - When `true`, returns client specific roles.
* @param {Boolean} [params.default] - When `true`, returns default roles only.
* @param {Object|String|Number} [params.org] - The organization object, name, id, or guid.
* @param {Boolean} [params.team] - When `true`, returns team specific roles.
* @returns {Promise<Object>}
*/
list: async (account, params = {}) => {
let roles = await this.request(
`/api/v1/role${params.team ? '?team=true' : ''}`,
account,
{ errorMsg: 'Failed to get roles' }
);
if (params.team) {
return roles.filter(r => r.team);
}
if (params.client) {
return roles.filter(r => r.client);
}
let org = params.org || account.org?.guid;
if (org) {
org = await this.org.find(account, org);
const { entitlements, subscriptions } = org;
roles = roles.filter(role => {
return role.org
&& (!role.partner || (entitlements.partners || []).includes(role.partner) && org[role.partner]?.provisioned)
&& (!role.entitlement || entitlements[role.entitlement])
&& (!role.subscription || subscriptions.find(sub => {
return new Date(sub.end_date) >= new Date() && role.subscription.includes(sub.product);
}));
});
}
if (params.default) {
return roles.filter(r => r.default);
}
return roles;
},
/**
* Fetches roles for the given params, then validates the supplied list of roles.
* @param {Object} account - The account object.
* @param {Array.<String>} roles - One or more roles to assign.
* @param {Object} [opts] - Various options.
* @param {Boolean} [opts.client] - When `true`, returns client specific roles.
* @param {Boolean} [opts.default] - When `true`, returns default roles only.
* @param {Object|String|Number} [opts.org] - The organization object, name, id, or guid.
* @param {Boolean} [opts.requireRoles] - When `true`, throws an error if roles is empty.
* @param {Boolean} [opts.requireDefaultRole] - When `true`, throws an error if roles is empty or if there are no default roles.
* @param {Boolean} [opts.team] - When `true`, validates team specific roles.
* @returns {Promise<Object>}
*/
resolve: async (account, roles, opts) => {
if (!Array.isArray(roles)) {
throw errors.INVALID_ARGUMENT('Expected roles to be an array');
}
if (!roles.length && !opts.requireRoles && !opts.requireDefaultRole) {
return [];
}
const allowedRoles = await this.role.list(account, {
client: opts.client,
default: opts.default,
org: opts.org,
team: opts.team
});
const defaultRoles = allowedRoles.filter(r => r.default).map(r => r.id);
if (!roles.length && opts.requireDefaultRole) {
throw new Error(`Expected at least one of the following roles: ${defaultRoles.join(', ')}`);
}
if (!roles.length && opts.requireRoles) {
throw new Error(`Expected at least one of the following roles: ${allowedRoles.join(', ')}`);
}
roles = roles
.reduce((arr, role) => arr.concat(role.split(',')), [])
.map(role => {
const lr = role.toLowerCase().trim();
const found = allowedRoles.find(ar => ar.id === lr || ar.name.toLowerCase() === lr);
if (!found) {
throw new Error(`Invalid role "${role}", expected one of the following: ${allowedRoles.map(r => r.id).join(', ')}`);
}
return found.id;
});
log(`Resolved roles: ${highlight(roles.join(', '))}`);
if (opts.requireDefaultRole && !roles.some(r => defaultRoles.includes(r))) {
throw new Error(`You must specify a default role: ${defaultRoles.join(', ')}`);
}
return roles;
}
};
/**
* Determines team info changes and prepares the team info to be sent.
* @param {Object} [info] - The new team info.
* @param {Object} [prev] - The previous team info.
* @returns {Promise<Object>}
*/
const prepareTeamInfo = (info = {}, prev) => {
if (!info || typeof info !== 'object') {
throw errors.INVALID_ARGUMENT('Expected team info to be an object');
}
const changes = {};
const data = {};
// populate data
if (info.default !== undefined) {
data.default = !!info.default;
}
if (info.desc !== undefined) {
data.desc = String(info.desc).trim();
}
if (info.name !== undefined) {
data.name = String(info.name).trim();
}
if (info.tags !== undefined) {
if (!Array.isArray(info.tags)) {
throw errors.INVALID_ARGUMENT('Expected team tags to be an array of strings');
}
data.tags = info.tags
.reduce((arr, tag) => arr.concat(tag.split(',')), [])
.map(tag => tag.trim());
}
// remove unchanged
if (prev) {
for (const key of Object.keys(data)) {
if (Array.isArray(data[key])) {
if (!(data[key] < prev[key] || data[key] > prev[key])) {
delete data[key];
}
} else if (data[key] === prev[key]) {
delete data[key];
} else {
changes[key] = {
v: data[key],
p: prev[key]
};
}
}
}
return { changes, data };
};
this.team = {
/**
* Creates a team in an org.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {String} name - The name of the team.
* @param {Object} [info] - The team info.
* @param {String} [info.desc] - The team description.
* @param {Boolean} [info.default] - When `true`, makes this team the default.
* @param {Array.<String>} [info.tags] - A list of tags.
* @returns {Promise<Object>}
*/
create: async (account, org, name, info) => {
org = this.resolvePlatformOrg(account, org);
if (!name || typeof name !== 'string') {
throw errors.INVALID_ARGUMENT('Expected name to be a non-empty string');
}
const { data } = prepareTeamInfo(info);
data.name = name;
data.org_guid = org.guid;
return {
org,
team: await this.request('/api/v1/team', account, {
errorMsg: 'Failed to create team',
json: data
})
};
},
/**
* Find a team by name or guid.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {String} team - The team name or guid.
* @returns {Promise<Object>}
*/
find: async (account, org, team) => {
org = this.resolvePlatformOrg(account, org);
if (!team || typeof team !== 'string') {
throw errors.INVALID_ARGUMENT('Expected team to be a name or guid');
}
const origTeam = team;
const { teams } = await this.team.list(account, org);
team = team.toLowerCase();
team = teams.find(t => t.name.toLowerCase() === team || t.guid === team);
if (!team) {
throw new Error(`Unable to find team "${origTeam}" in the "${org.name}" organization`);
}
return { org, team };
},
/**
* List all teams in an org.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {String} [user] - A user guid to filter teams
* @returns {Promise<Object>}
*/
list: async (account, org, user) => {
org = org && this.resolveOrg(account, org);
let teams = await this.request(`/api/v1/team${org?.id ? `?org_id=${org.id}` : ''}`, account, {
errorMsg: 'Failed to get organization teams'
});
if (user) {
teams = teams.filter(team => {
return team.users?.find(u => u.guid === user);
});
}
return {
org,
teams: teams.sort((a, b) => a.name.localeCompare(b.name))
};
},
user: {
/**
* Adds a user to a team.
* @param {Object} account - The account object.
* @param {Object|String|Number} org - The organization object, name, id, or guid.
* @param {String} team - The team or guid.
* @param {String} user - The user email or guid.
* @param {Array.<String>} roles - One or more roles to assign. Must include a "default" role.
* @returns {Promise<Object>}
*/
add: async (account, org, team, user, roles) => {
({ org, team } = await this.team.find(account, org, team));
const found = await this.org.user.find(account, org.guid, user);
if (!found) {
throw new Error(`Unable to find the user "${user}"`);
}
return {
org,
team: await this.request(`/api/v1/team/${team.guid}/user/${found.guid}`, account, {
errorMsg: 'Failed to add user to organization',
json: {
roles: await this.role.resolve(account, roles, { org, requireRoles: true, team: true }