UNPKG

@axway/amplify-sdk

Version:

Axway Amplify SDK for Node.js

1,499 lines (1,327 loc) 66.7 kB
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 }