From 63dd5c44312c4ff84115919ee9e625d78b2ca631 Mon Sep 17 00:00:00 2001 From: Keyvan Chamani Date: Thu, 17 Oct 2024 17:32:20 -0400 Subject: [PATCH] Topic/k1ch/ Introduce include_permissions query param for `GET /roles` APIs (#126) * chore: topic/k1ch/ update knex configuration to avoid race condition * feat: topic/k1ch/ introduce includePermissions query for get/roles API * chore: bump version to 2.3.0-rc.1 * refactor: k1ch/ refactored view-select-relationships to use knex instead of PG pool * refactor: k1ch / modify admin-role db layer to use knex instead of PG pool * chore: k1ch / bump version to 2.3.0-rc.2 * chore: k1ch / ensure seedKeysIfDbIsEmpty does not cause Unhandled Promise Rejection * feat: k1ch/ introduce clients/client_id/roles?includePermissions=true * chore: k1ch/ try catch for seedKeysIfDbIsEmpty function * chore: k1ch/ add test for getPermissionsByRoleKey * chore: update OAS and the-usher.js * chore: k1ch/ add more tests for roles?include_permission=true * chore: minor updates --- database/knexfile.js | 22 ++- database/layer/admin-permission.js | 18 +++ database/layer/admin-role.js | 74 +++++---- database/layer/knex.js | 13 +- database/layer/view-select-relationships.js | 145 +++++++----------- database/package-lock.json | 4 +- database/package.json | 2 +- database/test/db-admin-permissions.test.js | 11 ++ database/utils/pgErrorHandler.js | 5 + docs/INSTALL.md | 2 + server/.env.sample | 1 + server/package-lock.json | 6 +- server/package.json | 2 +- server/src/api_endpoints/clients/roles.js | 39 +++-- server/src/api_endpoints/endpoint_roles.js | 26 ++-- server/src/api_endpoints/personas/roles.js | 7 + server/src/api_endpoints/roles/role_key.js | 23 +-- .../security_layer/jwt_signature_validator.js | 52 ++++--- .../endpoint_admin_personas_roles.test.js | 26 +++- server/test/endpoint_clients.test.js | 49 ++++++ server/test/endpoint_roles.test.js | 101 +++++++++++- server/the-usher-openapi-spec.yaml | 107 +++++++++++-- server/the-usher.js | 32 ++-- 23 files changed, 536 insertions(+), 231 deletions(-) diff --git a/database/knexfile.js b/database/knexfile.js index 9fe4589..7c3433e 100644 --- a/database/knexfile.js +++ b/database/knexfile.js @@ -9,14 +9,22 @@ module.exports = { schemaName: env.PGSCHEMA, }, pool: { - min: +process.env.KNEX_POOL_MIN || 0, - max: +process.env.KNEX_POOL_MAX || 100, + min: +process.env.KNEX_POOL_MIN || 0, // Minimum number of connections in the pool + max: +process.env.KNEX_POOL_MAX || 100, // Maximum number of connections in the pool - // tarn config (https://www.npmjs.com/package/tarn) + /* + Tarn.js configuration (see https://www.npmjs.com/package/tarn for more details) + Default tarn.js settings: + propagateCreateError: true // Whether to propagate errors encountered during creation + createRetryIntervalMillis: 200 // Delay between retries when trying to create a new connection + createTimeoutMillis: 30000 // Time to wait before timing out when creating a new connection + acquireTimeoutMillis: 30000 // Time to wait before timing out when acquiring a connection from the pool + reapIntervalMillis: 1000 // Frequency of checking for idle resources to be destroyed + */ propagateCreateError: process.env.KNEX_POOL_PROPAGATE_CREATE_ERROR === 'true' || false, - createRetryIntervalMillis: +process.env.KNEX_POOL_CREATE_RETRY_INTERVAL_MILLIS || 500, - createTimeoutMillis: +process.env.KNEX_POOL_CREATE_TIMEOUT_MILLIS || 5000, - acquireTimeoutMillis: +process.env.KNEX_POOL_ACQUIRE_TIMEOUT_MILLIS || 5000, - reapIntervalMillis: +process.env.KNEX_POOL_REAP_INTERVAL_MILLIS || 1000, + createRetryIntervalMillis: +process.env.KNEX_POOL_CREATE_RETRY_INTERVAL_MILLIS || 200, + createTimeoutMillis: +process.env.KNEX_POOL_CREATE_TIMEOUT_MILLIS || 10000, + acquireTimeoutMillis: +process.env.KNEX_POOL_ACQUIRE_TIMEOUT_MILLIS || 10000, + reapIntervalMillis: +process.env.KNEX_POOL_REAP_INTERVAL_MILLIS || 500, }, } diff --git a/database/layer/admin-permission.js b/database/layer/admin-permission.js index f41fc91..7d15772 100644 --- a/database/layer/admin-permission.js +++ b/database/layer/admin-permission.js @@ -62,9 +62,27 @@ const getPermission = async (permissionKey) => { } } +/** + * Retrieve a list of permissions by role key + * + * @param {number} roleKey - The role key + * @returns {Promise>} - A promise that resolves to the list of permission objects associated with the role + */ +const getPermissionsByRoleKey = async (roleKey) => { + try { + return await usherDb('permissions') + .join('rolepermissions', 'permissions.key', '=', 'rolepermissions.permissionkey') + .where({ 'rolepermissions.rolekey': roleKey }) + .select('permissions.*'); + } catch (err) { + throw pgErrorHandler(err); + } +} + module.exports = { insertPermissionByClientId, updatePermissionByPermissionname, deletePermissionByPermissionname, getPermission, + getPermissionsByRoleKey, } diff --git a/database/layer/admin-role.js b/database/layer/admin-role.js index 7744823..9804a49 100644 --- a/database/layer/admin-role.js +++ b/database/layer/admin-role.js @@ -1,21 +1,13 @@ -const { PGPool } = require('./pg_pool') -const pool = new PGPool() +const { usherDb } = require('./knex') +const { pgErrorHandler } = require('../utils/pgErrorHandler') -module.exports = { - insertRoleByClientId, - updateRoleByClientRolename, - deleteRoleByClientRolename, - getRole, - listRoles -} - -async function insertRoleByClientId (clientId, rolename, roledescription) { - let sql = 'INSERT INTO usher.roles (clientkey, name, description) SELECT key, $1, $2 FROM usher.clients WHERE client_id = $3' +const insertRoleByClientId = async (clientId, rolename, roledescription) => { + let sql = 'INSERT INTO usher.roles (clientkey, name, description) SELECT key, ?, ? FROM usher.clients WHERE client_id = ?' try { - const results = await pool.query(sql, [rolename, roledescription, clientId]) + const results = await usherDb.raw(sql, [rolename, roledescription, clientId]) if (results.rowCount === 1) { - sql = 'SElECT r.key, r.clientkey, r.name, r.description FROM usher.roles r inner join usher.clients c on (r.clientkey=c.key AND c.client_id=$1) WHERE r.name=$2' - const role = await pool.query(sql, [clientId, rolename]) + sql = 'SElECT r.key, r.clientkey, r.name, r.description FROM usher.roles r inner join usher.clients c on (r.clientkey=c.key AND c.client_id=?) WHERE r.name=?' + const role = await usherDb.raw(sql, [clientId, rolename]) return role.rows[0] } else { throw new Error(`Client does not exist matching client_id ${clientId}`) @@ -29,11 +21,11 @@ async function insertRoleByClientId (clientId, rolename, roledescription) { } } -async function updateRoleByClientRolename (clientId, rolename, roledescription) { - const sql = 'UPDATE usher.roles r SET description = $1 WHERE EXISTS (SELECT 1 FROM usher.clients c WHERE c.client_id = $2) AND r.name = $3' +const updateRoleByClientRolename = async (clientId, rolename, roledescription) => { + const sql = 'UPDATE usher.roles r SET description = ? WHERE EXISTS (SELECT 1 FROM usher.clients c WHERE c.client_id = ?) AND r.name = ?' const sqlParams = [roledescription, clientId, rolename] try { - const updateResult = await pool.query(sql, sqlParams) + const updateResult = await usherDb.raw(sql, sqlParams) if (updateResult.rowCount === 1) { return 'Update successful' } else { @@ -50,17 +42,21 @@ async function updateRoleByClientRolename (clientId, rolename, roledescription) * @param {number} key The Role primary key * @returns Role object */ -async function getRole (key) { - const sql = 'SELECT r.* FROM usher.roles r WHERE r.key = $1' - const results = await pool.query(sql, [key]) - return results.rows[0] +const getRole = async (key) => { + try { + const sql = 'SELECT r.* FROM usher.roles r WHERE r.key = ?' + const results = await usherDb.raw(sql, [key]) + return results.rows[0] + } catch (err) { + throw pgErrorHandler(err) + } } -async function deleteRoleByClientRolename (clientId, rolename) { - const sql = 'DELETE FROM usher.roles r WHERE EXISTS (SELECT 1 FROM usher.clients c WHERE c.client_id = $1) AND r.name = $2' +const deleteRoleByClientRolename = async (clientId, rolename) => { + const sql = 'DELETE FROM usher.roles r WHERE EXISTS (SELECT 1 FROM usher.clients c WHERE c.client_id = ?) AND r.name = ?' const sqlParams = [clientId, rolename] try { - const results = await pool.query(sql, sqlParams) + const results = await usherDb.raw(sql, sqlParams) if (results.rowCount === 1) { return 'Delete successful' } else { @@ -78,20 +74,32 @@ async function deleteRoleByClientRolename (clientId, rolename) { * @param {string} [clientId] Optional client_id to filter list of Roles belonging to given Client * @returns Array of Role objects with associated extra fields from tenants, clients */ -async function listRoles (clientId) { - const params = [] - let sql = `SELECT t.name AS tenantname, t.iss_claim AS iss_claim, +const listRoles = async (clientId) => { + try { + const params = [] + let sql = `SELECT t.name AS tenantname, t.iss_claim AS iss_claim, c.client_id AS client_id, r.key, r.clientkey, r.name, r.description FROM usher.roles r JOIN usher.clients c ON (c.key = r.clientkey) JOIN usher.tenantclients tc ON (c.key = tc.clientkey) JOIN usher.tenants t ON (t.key = tc.tenantkey) WHERE 1=1 ` - if (clientId) { - sql += ' and c.client_id = $1' - params.push(clientId) + if (clientId) { + sql += ' and c.client_id = ?' + params.push(clientId) + } + + const roles = await usherDb.raw(sql, params) + return roles.rows + } catch (err) { + throw pgErrorHandler(err) } +} - const roles = await pool.query(sql, params) - return roles.rows +module.exports = { + insertRoleByClientId, + updateRoleByClientRolename, + deleteRoleByClientRolename, + getRole, + listRoles, } diff --git a/database/layer/knex.js b/database/layer/knex.js index c65f61b..2403df5 100644 --- a/database/layer/knex.js +++ b/database/layer/knex.js @@ -16,15 +16,12 @@ try { usherDb = knex(knexDbConfig) if (usherDb?.client?.pool) { const pool = usherDb.client.pool - // Set idle timeout to 0 to release connections immediately. This can't be configured through Knex. + /* + Set idleTimeoutMillis to 0 to ensure connections are released during the next pool.check() interval. + The check interval can be configured using reapIntervalMillis. + Default idleTimeoutMillis is 30000 ms. + */ pool.idleTimeoutMillis = 0 - - // Check the pool for idle connections on 'release' event - pool.on('release', () => { - process.nextTick(() => { - pool.check() // Ensures the pool checks for idle connections immediately - }) - }) } } catch (err) { console.error('Failed to create knex instance: ', JSON.stringify(err)) diff --git a/database/layer/view-select-relationships.js b/database/layer/view-select-relationships.js index e36075e..c8f40ee 100644 --- a/database/layer/view-select-relationships.js +++ b/database/layer/view-select-relationships.js @@ -1,7 +1,6 @@ -const { PGPool } = require('./pg_pool') -const pool = new PGPool() +const { usherDb } = require('./knex') -function getTenantPersonaClientsView () { +const getTenantPersonaClientsView = () => { return `SELECT DISTINCT c.client_id, c.name AS clientname FROM usher.tenants t JOIN usher.tenantclients tc ON t.key = tc.tenantkey @@ -18,35 +17,31 @@ function getTenantPersonaClientsView () { JOIN usher.personas p ON rp.personakey = p.key AND p.tenantkey = t.key` } -async function selectTenantPersonaClients (subClaim = '*', userContext = '*', clientId = '*') { +const selectTenantPersonaClients = async (subClaim = '*', userContext = '*', clientId = '*') => { try { let sql = `${getTenantPersonaClientsView()} WHERE 1=1` const params = [] - let paramCount = 0 if (subClaim !== '*') { params.push(subClaim) - paramCount++ - sql += ' AND p.sub_claim = $' + paramCount + sql += ' AND p.sub_claim = ?' } if (userContext !== '*') { params.push(userContext) - paramCount++ - sql += ' AND p.user_context = $' + paramCount + sql += ' AND p.user_context = ?' } if (clientId !== '*') { params.push(clientId) - paramCount++ - sql += ' AND c.client_id = $' + paramCount + sql += ' AND c.client_id = ?' } sql += ' ORDER BY client_id, clientname' - const results = await pool.query(sql, params) + const results = await usherDb.raw(sql, params) return results.rows } catch (error) { throw error.message } } -function getTenantPersonaClientRolesView () { +const getTenantPersonaClientRolesView = () => { return `SELECT t.iss_claim, t.name AS tenantname, p.sub_claim, p.user_context, c.client_id, c.name AS clientname, r.name AS rolename, r.description AS roledescription @@ -58,64 +53,56 @@ function getTenantPersonaClientRolesView () { JOIN usher.personas p ON ur.personakey = p.key AND p.tenantkey = t.key` } -async function selectTenantPersonaClientRoles (subClaim = '*', userContext = '*', clientId = '*') { +const selectTenantPersonaClientRoles = async (subClaim = '*', userContext = '*', clientId = '*') => { try { let sql = getTenantPersonaClientRolesView() + ' WHERE 1=1' const params = [] - let paramCount = 0 if (subClaim !== '*') { params.push(subClaim) - paramCount++ - sql += ' AND p.sub_claim = $' + paramCount + sql += ' AND p.sub_claim = ?' } if (userContext !== '*') { params.push(userContext) - paramCount++ - sql += ' AND p.user_context = $' + paramCount + sql += ' AND p.user_context = ?' } if (clientId !== '*') { params.push(clientId) - paramCount++ - sql += ' AND c.client_id = $' + paramCount + sql += ' AND c.client_id = ?' } sql += ' ORDER BY tenantname, sub_claim, client_id, user_context, rolename' - const results = await pool.query(sql, params) + const results = await usherDb.raw(sql, params) return results.rows } catch (error) { throw error.message } } -async function selectClientsByTenantPersonaRole (subClaim = '*', userContext = '*', rolename = '*') { +const selectClientsByTenantPersonaRole = async (subClaim = '*', userContext = '*', rolename = '*') => { try { let sql = getTenantPersonaClientRolesView() + ' WHERE 1=1' const params = [] - let paramCount = 0 if (subClaim !== '*') { params.push(subClaim) - paramCount++ - sql += ' AND p.sub_claim = $' + paramCount + sql += ' AND p.sub_claim = ?' } if (userContext !== '*') { params.push(userContext) - paramCount++ - sql += ' AND p.user_context = $' + paramCount + sql += ' AND p.user_context = ?' } // This is specifically set as LIKE to allow the generation of %:client-admin roles if (rolename !== '*') { params.push(rolename) - paramCount++ - sql += ' AND r.name LIKE $' + paramCount + sql += ' AND r.name LIKE ?' } sql += ' ORDER BY tenantname, sub_claim, user_context, client_id, rolename' - const results = await pool.query(sql, params) + const results = await usherDb.raw(sql, params) return results.rows } catch (error) { throw error.message } } -function getTenantPersonaClientRolePermissionsView () { +const getTenantPersonaClientRolePermissionsView = () => { return `SELECT t.iss_claim, t.name AS tenantname, @@ -154,35 +141,31 @@ function getTenantPersonaClientRolePermissionsView () { AND (pm.clientkey IS NULL OR pm.clientkey = c.key)` } -async function selectTenantPersonaClientRolePermissions (subClaim = '*', userContext = '*', clientId = '*') { +const selectTenantPersonaClientRolePermissions = async (subClaim = '*', userContext = '*', clientId = '*') => { try { let sql = getTenantPersonaClientRolePermissionsView() + ' WHERE 1=1' const params = [] - let paramCount = 0 if (subClaim !== '*') { params.push(subClaim) - paramCount++ - sql += ' AND p.sub_claim = $' + paramCount + sql += ' AND p.sub_claim = ?' } if (userContext !== '*') { params.push(userContext) - paramCount++ - sql += ' AND p.user_context = $' + paramCount + sql += ' AND p.user_context = ?' } if (clientId !== '*') { params.push(clientId) - paramCount++ - sql += ' AND c.client_id = $' + paramCount + sql += ' AND c.client_id = ?' } sql += ' ORDER BY tenantname, sub_claim, user_context, client_id, rolename, permissionname' - const results = await pool.query(sql, params) + const results = await usherDb.raw(sql, params) return results.rows } catch (error) { throw error.message } } -function getTenantPersonaPermissionsView () { +const getTenantPersonaPermissionsView = () => { return `SELECT c.client_id, p.sub_claim, pm.name AS permissionname FROM usher.tenants t JOIN usher.tenantclients tc ON t.key = tc.tenantkey @@ -192,52 +175,46 @@ function getTenantPersonaPermissionsView () { JOIN usher.permissions pm ON (pp.permissionkey = pm.KEY AND pm.clientkey = c.key)` } -async function selectTenantPersonaPermissions (clientId = '*', subClaim = '*') { +const selectTenantPersonaPermissions = async (clientId = '*', subClaim = '*') => { try { let sql = getTenantPersonaPermissionsView() + ' WHERE 1=1' const params = [] - let paramCount = 0 if (clientId !== '*') { params.push(clientId) - paramCount++ - sql += ' AND c.client_id = $' + paramCount + sql += ' AND c.client_id = ?' } if (subClaim !== '*') { params.push(subClaim) - paramCount++ - sql += ' AND p.sub_claim = $' + paramCount + sql += ' AND p.sub_claim = ?' } sql += ' ORDER BY client_id, sub_claim, permissionname' - const results = await pool.query(sql, params) + const results = await usherDb.raw(sql, params) return results.rows } catch (error) { throw error.message } } -function getClientRolesView () { +const getClientRolesView = () => { return `SELECT c.client_id, c.name AS clientname, r.name AS rolename, r.description AS roledescription FROM usher.clients c JOIN usher.roles r ON r.clientkey = c.key` } -async function selectClientRoles (clientId = '*', rolename = '*') { +const selectClientRoles = async (clientId = '*', rolename = '*') => { try { let sql = getClientRolesView() + ' WHERE 1=1' const params = [] - let paramCount = 0 if (clientId !== '*') { params.push(clientId) - paramCount++ - sql += ' AND c.client_id = $' + paramCount + sql += ' AND c.client_id = ?' } if (rolename !== '*') { params.push(rolename) - paramCount++ - sql += ' AND r.name = $' + paramCount + sql += ' AND r.name = ?' } sql += ' ORDER BY client_id, rolename' - const results = await pool.query(sql, params) + const results = await usherDb.raw(sql, params) return results.rows } catch (error) { throw error.message @@ -252,9 +229,8 @@ async function selectClientRoles (clientId = '*', rolename = '*') { * @param {*} clientId * @returns */ -async function selectSelfRoles (subClaim, userContext = '*', clientId = '*') { +const selectSelfRoles = async (subClaim, userContext = '*', clientId = '*') => { const params = [subClaim] - let paramCount = 1 let sql = `SELECT DISTINCT r.key, r.clientkey, r.name, r.description FROM usher.tenants t JOIN usher.tenantclients tc ON t.key = tc.tenantkey @@ -262,28 +238,26 @@ async function selectSelfRoles (subClaim, userContext = '*', clientId = '*') { JOIN usher.roles r ON r.clientkey = c.key JOIN usher.personaroles ur ON ur.rolekey = r.key JOIN usher.personas p ON ur.personakey = p.key AND p.tenantkey = t.key - WHERE p.sub_claim = $1 ` + WHERE p.sub_claim = ? ` try { if (userContext !== '*') { params.push(userContext) - paramCount++ - sql += ' AND p.user_context = $' + paramCount + sql += ' AND p.user_context = ?' } if (clientId !== '*') { params.push(clientId) - paramCount++ - sql += ' AND c.client_id = $' + paramCount + sql += ' AND c.client_id = ?' } - const results = await pool.query(sql, params) + const results = await usherDb.raw(sql, params) return results.rows } catch (error) { throw error.message } } -async function selectSelfPermissions (subClaim = '*', userContext = '*', clientId = '*') { +const selectSelfPermissions = async (subClaim = '*', userContext = '*', clientId = '*') => { let sql = `SELECT DISTINCT pm.name AS permission FROM usher.tenants t JOIN usher.tenantclients tc ON t.key = tc.tenantkey @@ -296,32 +270,28 @@ async function selectSelfPermissions (subClaim = '*', userContext = '*', clientI JOIN usher.permissions pm ON ((rp.permissionkey = pm.KEY OR pp.permissionkey = pm.KEY) AND pm.clientkey = c.key) WHERE 1=1` const params = [] - let paramCount = 0 try { if (subClaim !== '*') { params.push(subClaim) - paramCount++ - sql += ' AND p.sub_claim = $' + paramCount + sql += ' AND p.sub_claim = ?' } if (userContext !== '*') { params.push(userContext) - paramCount++ - sql += ' AND p.user_context = $' + paramCount + sql += ' AND p.user_context = ?' } if (clientId !== '*') { params.push(clientId) - paramCount++ - sql += ' AND c.client_id = $' + paramCount + sql += ' AND c.client_id = ?' } - const results = await pool.query(sql, params) + const results = await usherDb.raw(sql, params) return results.rows } catch (error) { throw error.message } } -async function selectSelfScope (subClaim = '*', userContext = '*', clientId = '*') { +const selectSelfScope = async (subClaim = '*', userContext = '*', clientId = '*') => { let sql = `SELECT DISTINCT r.name as role, pm.name AS permission FROM usher.tenants t JOIN usher.tenantclients tc ON t.key = tc.tenantkey @@ -334,25 +304,21 @@ async function selectSelfScope (subClaim = '*', userContext = '*', clientId = '* JOIN usher.permissions pm ON ((rp.permissionkey = pm.KEY OR pp.permissionkey = pm.KEY) AND pm.clientkey = c.key) WHERE 1=1` const params = [] - let paramCount = 0 try { if (subClaim !== '*') { params.push(subClaim) - paramCount++ - sql += ' AND p.sub_claim = $' + paramCount + sql += ' AND p.sub_claim = ?' } if (userContext !== '*') { params.push(userContext) - paramCount++ - sql += ' AND p.user_context = $' + paramCount + sql += ' AND p.user_context = ?' } if (clientId !== '*') { params.push(clientId) - paramCount++ - sql += ' AND c.client_id = $' + paramCount + sql += ' AND c.client_id = ?' } - const results = await pool.query(sql, params) + const results = await usherDb.raw(sql, params) return results.rows } catch (error) { throw error.message @@ -363,7 +329,7 @@ async function selectSelfScope (subClaim = '*', userContext = '*', clientId = '* * @deprecated * @returns */ -function getClientRolePermissionsView () { +const getClientRolePermissionsView = () => { return `SELECT DISTINCT c.client_id, c.name AS clientname, r.name AS rolename, r.description AS roledescription, pm.name AS permissionname, pm.description AS permissiondescription FROM usher.clients c @@ -381,23 +347,20 @@ function getClientRolePermissionsView () { * @param {*} rolename * @returns */ -async function selectClientRolePermissions (clientId = '*', rolename = '*') { +const selectClientRolePermissions = async (clientId = '*', rolename = '*') => { try { let sql = getClientRolePermissionsView() + ' WHERE 1=1' const params = [] - let paramCount = 0 if (clientId !== '*') { params.push(clientId) - paramCount++ - sql += ' AND c.client_id = $' + paramCount + sql += ' AND c.client_id = ?' } if (rolename !== '*') { params.push(rolename) - paramCount++ - sql += ' AND r.name = $' + paramCount + sql += ' AND r.name = ?' } sql += ' ORDER BY client_id, rolename, permissionname' - const results = await pool.query(sql, params) + const results = await usherDb.raw(sql, params) return results.rows } catch (error) { throw error.message diff --git a/database/package-lock.json b/database/package-lock.json index 47dd9c1..cf013d0 100644 --- a/database/package-lock.json +++ b/database/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dmgt-tech/the-usher-server-database", - "version": "2.2.1", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dmgt-tech/the-usher-server-database", - "version": "2.2.1", + "version": "2.3.0", "license": "MIT", "dependencies": { "dotenv": "16.4.5", diff --git a/database/package.json b/database/package.json index ab0083b..5dce95a 100644 --- a/database/package.json +++ b/database/package.json @@ -1,6 +1,6 @@ { "name": "@dmgt-tech/the-usher-server-database", - "version": "2.2.1", + "version": "2.3.0", "description": "Database layer for TheUsher", "scripts": { "test": "mocha --exit", diff --git a/database/test/db-admin-permissions.test.js b/database/test/db-admin-permissions.test.js index ad15f0d..a11f422 100644 --- a/database/test/db-admin-permissions.test.js +++ b/database/test/db-admin-permissions.test.js @@ -21,5 +21,16 @@ describe('Admin permissions view', () => { const permission = await adminPermissions.getPermission(0) assert.equal(permission, undefined) }) + + it('Should get the permissions for a role key', async () => { + const { rolekey, permission_count } = await usherDb('rolepermissions') + .select('rolekey') + .count('permissionkey as permission_count') + .groupBy('rolekey') + .orderBy('permission_count', 'desc') + .first(); + const permissions = await adminPermissions.getPermissionsByRoleKey(rolekey) + assert.equal(permission_count, permissions.length) + }) }) }) diff --git a/database/utils/pgErrorHandler.js b/database/utils/pgErrorHandler.js index 2573171..f555fd2 100644 --- a/database/utils/pgErrorHandler.js +++ b/database/utils/pgErrorHandler.js @@ -33,6 +33,11 @@ const pgErrorHandler = (pgDbError) => { error.httpStatusCode = 400 break + case PgErrorCodes.UndefinedColumn: + error.message = 'Internal DB Error: Bad query - Specified column is invalid!' + error.httpStatusCode = 500 + break + case PgErrorCodes.SerializationFailure: error.message = 'Internal DB Error: A transaction serialization error occurred!' error.httpStatusCode = 500 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 13093ad..febb2b6 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -70,6 +70,8 @@ The following variables are required to be configured. | THEUSHER_AUD_CLAIMS | (Optional) Comma separated list of authorized audience (aud) claims | | PRESET_SERVER_URL | (Optional) URI to use as `iss` claim for issued tokens | | ISSUER_ALIASES | (Optional && Experimental) [Hostname aliases](USAGE.md#migrating-idenitity-provider-domain-names-issuer-aliases-experimental) for IdP tokens issuer | +| SKIP_KEYS_CHECK | (Optional) Skips `seedKeysIfDbIsEmpty` on application bootstrap if the environment variable value is set to `true`. | + ## Generic Installation Steps diff --git a/server/.env.sample b/server/.env.sample index 2ce50cd..0587c1c 100644 --- a/server/.env.sample +++ b/server/.env.sample @@ -34,3 +34,4 @@ PRESET_SERVER_URL=http://localhost:3001 ISSUER_WHITELIST=https://dmgt-test.auth0.com/,test1.net,foo,https://auth.labs.dmgt.com/,http://branded-idp-alias.dmgt.com.mock.localhost:3002/,http://idp.dmgt.com.mock.localhost:3002/,http://whitelisted-but-not-aliased.labs.dmgt.com.mock.localhost:3002/ ISSUER_ALIASES='{"https://auth.labs.dmgt.com/": "https://dmgt-test.auth0.com/", "http://branded-idp-alias.dmgt.com.mock.localhost:3002/": "http://idp.dmgt.com.mock.localhost:3002/"}' THEUSHER_AUD_CLAIMS=https://us-central1-dmgt-oocto.cloudfunctions.net/the-usher,http://localhost:3001 +SKIP_KEYS_CHECK=true diff --git a/server/package-lock.json b/server/package-lock.json index 691cb6b..9b4f019 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dmgt-tech/the-usher-server", - "version": "2.2.1", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dmgt-tech/the-usher-server", - "version": "2.2.1", + "version": "2.3.0", "license": "MIT", "dependencies": { "cors": "2.8.5", @@ -38,7 +38,7 @@ }, "../database": { "name": "@dmgt-tech/the-usher-server-database", - "version": "2.2.1", + "version": "2.3.0", "license": "MIT", "dependencies": { "dotenv": "16.4.5", diff --git a/server/package.json b/server/package.json index fe319b5..fc5f493 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@dmgt-tech/the-usher-server", - "version": "2.2.1", + "version": "2.3.0", "description": "The Usher Authorization Server", "engines": { "node": ">=18" diff --git a/server/src/api_endpoints/clients/roles.js b/server/src/api_endpoints/clients/roles.js index f4aa347..a52f296 100644 --- a/server/src/api_endpoints/clients/roles.js +++ b/server/src/api_endpoints/clients/roles.js @@ -1,10 +1,7 @@ const createError = require('http-errors') -const dbAdminRole = require('database/layer/admin-role') - -module.exports = { - listClientRoles, - createClientRole -} +const dbAdminRoles = require('database/layer/admin-role') +const dbAdminPermissions = require('database/layer/admin-permission') +const { checkClientExists } = require('./utils') /** * Client Admin function to get a list of Roles for given Client @@ -13,11 +10,21 @@ module.exports = { * @param {*} res * @param {*} next */ -async function listClientRoles (req, res, next) { - const clientId = req.params.client_id - - const roles = await dbAdminRole.listRoles(clientId) - res.status(200).send({ data: roles }) +const listClientRoles = async (req, res, next) => { + try { + const { client_id: clientId } = req.params + const { include_permissions: includePermissions } = req.query + await checkClientExists(clientId) + const roles = await dbAdminRoles.listRoles(clientId) + if (includePermissions === 'true') { + for (const role of roles) { + role.permissions = await dbAdminPermissions.getPermissionsByRoleKey(role.key) + } + } + res.status(200).send({ data: roles }) + } catch ({ httpStatusCode = 500, message }) { + return next(createError(httpStatusCode, { message })) + } } /** @@ -27,15 +34,21 @@ async function listClientRoles (req, res, next) { * @param {*} res * @param {*} next */ -async function createClientRole (req, res, next) { +const createClientRole = async (req, res, next) => { const clientId = req.params.client_id const name = req.body.name const description = req.body.description try { - const role = await dbAdminRole.insertRoleByClientId(clientId, name, description) + await checkClientExists(clientId) + const role = await dbAdminRoles.insertRoleByClientId(clientId, name, description) res.status(201).send(role) } catch (err) { return next(createError(500, err)) } } + +module.exports = { + listClientRoles, + createClientRole, +} diff --git a/server/src/api_endpoints/endpoint_roles.js b/server/src/api_endpoints/endpoint_roles.js index 7b1aa8c..b3ba5d4 100644 --- a/server/src/api_endpoints/endpoint_roles.js +++ b/server/src/api_endpoints/endpoint_roles.js @@ -1,17 +1,23 @@ const createError = require('http-errors') const dbAdminRole = require('database/layer/admin-role') +const dbAdminPermissions = require('database/layer/admin-permission') -module.exports = { getRoles, createRole } - -async function getRoles (req, res, next) { - const clientId = req.query.client_id - - // TODO refactor as part of issue #235. - const roles = await dbAdminRole.listRoles(clientId) - res.status(200).send({ data: roles }) +const getRoles = async (req, res, next) => { + try { + const { client_id: clientId, include_permissions: includePermissions } = req.query + const roles = await dbAdminRole.listRoles(clientId) + if (includePermissions === 'true') { + for (const role of roles) { + role.permissions = await dbAdminPermissions.getPermissionsByRoleKey(role.key) + } + } + res.status(200).send({ data: roles }) + } catch ({ httpStatusCode = 500, message }) { + return next(createError(httpStatusCode, { message })) + } } -async function createRole (req, res, next) { +const createRole = async (req, res, next) => { // perm access checked by middleware let role try { @@ -26,3 +32,5 @@ async function createRole (req, res, next) { } res.status(201).send(role) } + +module.exports = { getRoles, createRole } diff --git a/server/src/api_endpoints/personas/roles.js b/server/src/api_endpoints/personas/roles.js index 58d209e..ffc2bcd 100644 --- a/server/src/api_endpoints/personas/roles.js +++ b/server/src/api_endpoints/personas/roles.js @@ -1,12 +1,19 @@ const createError = require('http-errors') const dbAdminPersonaRoles = require('database/layer/admin-personarole') +const dbAdminPermissions = require('database/layer/admin-permission') const { checkPersonaExists, checkPersonaRolesValidity, checkRoleExists } = require('./utils') const getPersonaRoles = async (req, res, next) => { try { const { persona_key: personaKey } = req.params + const { include_permissions: includePermissions } = req.query await checkPersonaExists(personaKey) const roles = await dbAdminPersonaRoles.getPersonaRoles(personaKey) + if (includePermissions === 'true') { + for (const role of roles) { + role.permissions = await dbAdminPermissions.getPermissionsByRoleKey(role.key) + } + } res.status(200).send(roles) } catch ({ httpStatusCode = 500, message }) { return next(createError(httpStatusCode, { message })) diff --git a/server/src/api_endpoints/roles/role_key.js b/server/src/api_endpoints/roles/role_key.js index 55b6900..b069624 100644 --- a/server/src/api_endpoints/roles/role_key.js +++ b/server/src/api_endpoints/roles/role_key.js @@ -1,11 +1,6 @@ const createError = require('http-errors') const dbAdminRole = require('database/layer/admin-role') - -module.exports = { - getRole, - patchRole, - deleteRole -} +const dbAdminPermissions = require('database/layer/admin-permission') /** * Usher admin function to get a single Role object @@ -15,13 +10,17 @@ module.exports = { * @param {*} next * @returns Role object */ -async function getRole (req, res, next) { +const getRole = async (req, res, next) => { const roleKey = req.params.role_key + const { include_permissions } = req.query try { const role = await dbAdminRole.getRole(roleKey) if (!role) { return next(createError(404, 'Role key not found or no access')) } + if (include_permissions === 'true') { + role.permissions = await dbAdminPermissions.getPermissionsByRoleKey(role.key) + } res.status(200).send(role) } catch (err) { return next(createError(500, err)) @@ -35,7 +34,7 @@ async function getRole (req, res, next) { * @param {*} res * @param {*} next */ -async function patchRole (req, res, next) { +const patchRole = async (req, res, next) => { // const roleKey = req.params.role_key // try { // const role = await getRole(req, res, next) @@ -55,7 +54,7 @@ async function patchRole (req, res, next) { * @param {*} res * @param {*} next */ -async function deleteRole (req, res, next) { +const deleteRole = async (req, res, next) => { // const roleKey = req.params.role_key // try { // const role = await getRole(req, res, next) @@ -67,3 +66,9 @@ async function deleteRole (req, res, next) { // return next(createError(500, err)) // } } + +module.exports = { + getRole, + patchRole, + deleteRole +} diff --git a/server/src/security_layer/jwt_signature_validator.js b/server/src/security_layer/jwt_signature_validator.js index 749669a..2f79dac 100644 --- a/server/src/security_layer/jwt_signature_validator.js +++ b/server/src/security_layer/jwt_signature_validator.js @@ -1,12 +1,12 @@ -const jwtDecoder = require('jsonwebtoken') const jwksRsa = require('jwks-rsa') +const jwtDecoder = require('jsonwebtoken') +const createError = require('http-errors') const viewSelectEntities = require('database/layer/view-select-entities') const viewSelectRelationships = require('database/layer/view-select-relationships') -const jwksClients = {} -const createError = require('http-errors') const env = require('../../server-env') +const jwksClients = {} -async function verifyAndDecodeToken (token) { +const verifyAndDecodeToken = async (token) => { if (!token) { throw createError(403, 'Forbidden: No IdP JWT provided.') } @@ -28,7 +28,12 @@ async function verifyAndDecodeToken (token) { } const possiblyAliased = env?.ISSUER_ALIASES?.[issuerClaim] ?? issuerClaim - const tenant = await viewSelectEntities.selectIssuerJWKS(possiblyAliased) + let tenant + try { + tenant = await viewSelectEntities.selectIssuerJWKS(possiblyAliased) + } catch (err) { + return next(createError(500, `Internal Server Error: ${err}`)) + } if (tenant.length === 0) { throw createError(500, 'Internal Server Error: Could not determine IdP JWKS for IdP token issuer ' + issuerClaim + '. Tenant not registered?') @@ -89,18 +94,21 @@ async function verifyAndDecodeToken (token) { * @param {string} requiredRoleName Either a full role name or suffix to match, ie: client-admin * @returns {boolean} True if the user has access to at least one of the roles */ -async function verifyRoleGroupAccess (subClaim, userContext, clientId, requiredRoleName) { - const userRoles = await viewSelectRelationships.selectTenantPersonaClientRoles(subClaim, userContext, clientId) - const roleAccess = userRoles.map(x => x.rolename) - .some(x => { - const roleParts = x.split(':') - const roleSuffix = roleParts.length > 0 ? roleParts.pop() : '' - return x === requiredRoleName || requiredRoleName === roleSuffix - }) - // TODO Logic: Query DB to check if any groups in IdP token grant admin access. If yes, grant access. - const idpGroupsAccess = false - - return roleAccess || idpGroupsAccess +const verifyRoleGroupAccess = async (subClaim, userContext, clientId, requiredRoleName) => { + try { + const userRoles = await viewSelectRelationships.selectTenantPersonaClientRoles(subClaim, userContext, clientId) + const roleAccess = userRoles.map(x => x.rolename) + .some(x => { + const roleParts = x.split(':') + const roleSuffix = roleParts.length > 0 ? roleParts.pop() : '' + return x === requiredRoleName || requiredRoleName === roleSuffix + }) + // TODO Logic: Query DB to check if any groups in IdP token grant admin access. If yes, grant access. + const idpGroupsAccess = false + return roleAccess || idpGroupsAccess + } catch (err) { + return next(createError(500, `Internal Server Error: ${err}`)) + } } /** @@ -108,7 +116,7 @@ async function verifyRoleGroupAccess (subClaim, userContext, clientId, requiredR * In particular, it checks that the persona (sub) or claimed groups are * managed on this server for the tenant. */ -async function verifyTokenForSelf (req, secDef, token, next) { +const verifyTokenForSelf = async (req, secDef, token, next) => { try { const payload = await verifyAndDecodeToken(token) // If the token isn't verified an exception will be thrown let personaIsManagedOnThisServerForTenant = false @@ -135,7 +143,7 @@ async function verifyTokenForSelf (req, secDef, token, next) { } } -async function verifyTokenForAdmin (req, secDef, token, next) { +const verifyTokenForAdmin = async (req, secDef, token, next) => { try { const payload = await verifyAndDecodeToken(token) const roleName = 'the-usher:usher-admin' @@ -149,7 +157,7 @@ async function verifyTokenForAdmin (req, secDef, token, next) { next() } else { return next(createError(401, 'Unauthorized: Persona does not have Admin on this client or the-usher ' + - 'and did not obtain Admin via group membership.' + 'and did not obtain Admin via group membership.' )) } } catch (err) { @@ -157,7 +165,7 @@ async function verifyTokenForAdmin (req, secDef, token, next) { } } -async function verifyTokenForClientAdmin (req, secDef, token, next) { +const verifyTokenForClientAdmin = async (req, secDef, token, next) => { try { const payload = await verifyAndDecodeToken(token) const clientId = req.header('client_id') ? req.header('client_id') : '*' @@ -171,7 +179,7 @@ async function verifyTokenForClientAdmin (req, secDef, token, next) { next() } else { return next(createError(401, 'Unauthorized: Persona does not have Client Admin on this client ' + - 'and did not obtain Admin via group membership.' + 'and did not obtain Admin via group membership.' )) } } catch (err) { diff --git a/server/test/endpoint_admin_personas_roles.test.js b/server/test/endpoint_admin_personas_roles.test.js index 1b029fc..5fb8358 100644 --- a/server/test/endpoint_admin_personas_roles.test.js +++ b/server/test/endpoint_admin_personas_roles.test.js @@ -38,24 +38,40 @@ describe('Admin Personas Roles', () => { }) describe('GET:/personas/{persona_key}/roles', () => { - const getPersonasRoles = async (personaKey = testPersonaKey, header = requestHeaders) => { - return await fetch(`${url}/personas/${personaKey}/roles`, { + let validPersonaKey + const getPersonasRoles = async (personaKey = testPersonaKey, header = requestHeaders, queryParam = '') => { + return await fetch(`${url}/personas/${personaKey}/roles${queryParam}`, { method: 'GET', headers: header, }) } - it('should return 200 and a list of roles for the persona', async function () { + before(async () => { const { personakey } = await usherDb('personaroles').select('*').first() || {} - if (!personakey) { + validPersonaKey = personakey + }) + + it('should return 200 and a list of roles for the persona', async function () { + if (!validPersonaKey) { this.skip() } - const response = await getPersonasRoles(personakey) + const response = await getPersonasRoles(validPersonaKey) assert.equal(response.status, 200) const personaRoles = await response.json() assert.equal(!!personaRoles?.length, true) }) + it('should return 200 and a list of roles for the persona which includes permissions', async function () { + if (!validPersonaKey) { + this.skip() + } + const response = await getPersonasRoles(validPersonaKey, requestHeaders, '?include_permissions=true') + assert.equal(response.status, 200) + const personaRoles = await response.json() + assert.ok(Array.isArray(personaRoles)) + assert.ok(personaRoles.every(role => Array.isArray(role.permissions))) + }) + it('should return 200 and an empty array', async () => { const response = await getPersonasRoles(testPersonaKey) assert.equal(response.status, 200) diff --git a/server/test/endpoint_clients.test.js b/server/test/endpoint_clients.test.js index 302e534..dca207c 100644 --- a/server/test/endpoint_clients.test.js +++ b/server/test/endpoint_clients.test.js @@ -215,4 +215,53 @@ describe('Admin Clients Endpoint Test', () => { assert.equal(response.status, 401) }) }) + + describe('Get Client roles', () => { + let validClientId + const getClientRoles = async (clientId, queryParam = '', header = requestHeaders) => { + return await fetch(`${url}/clients/${clientId}/roles${queryParam}`, { + method: 'GET', + headers: header, + }) + } + + before(async () => { + const client = await usherDb('clients').select('*').first() + validClientId = client.client_id + }) + + it('should return 200 and list of all roles', async () => { + const response = await getClientRoles(validClientId) + assert.equal(response.status, 200) + const { data: roles } = await response.json() + assert.ok(roles?.length) + }) + + it('should return 200 and list of all roles which includes permissions', async () => { + const response = await getClientRoles(validClientId, '?include_permissions=true') + assert.equal(response.status, 200) + const { data: roles } = await response.json() + assert.ok(roles?.length) + assert.ok(roles.every(role => Array.isArray(role.permissions))) + }) + + it('should return 400 for invalid value for the include_permissions query parameter', async () => { + const response = await getClientRoles(validClientId, '?include_permissions=invalid') + assert.equal(response.status, 400) + }) + + it('should return 401 due to lack of proper token', async () => { + const userAccessToken = await getTestUser1IdPToken() + const response = await getClientRoles(validClientId, '', { + ...requestHeaders, + Authorization: `Bearer ${userAccessToken}` + }) + assert.equal(response.status, 401) + }) + + it('should return 404 for non-existent client id', async () => { + const response = await getClientRoles('invalid_client_id') + assert.equal(response.status, 404) + }) + }) }) diff --git a/server/test/endpoint_roles.test.js b/server/test/endpoint_roles.test.js index 42f01a9..c9afe80 100644 --- a/server/test/endpoint_roles.test.js +++ b/server/test/endpoint_roles.test.js @@ -1,9 +1,10 @@ const { describe, it } = require('mocha') const fetch = require('node-fetch') const assert = require('node:assert') -const { getAdmin1IdPToken } = require('./lib/tokens') +const { getAdmin1IdPToken, getTestUser1IdPToken } = require('./lib/tokens') const { getServerUrl } = require('./lib/urls') const dbAdminRole = require('database/layer/admin-role') +const { usherDb } = require('database/layer/knex') describe('Admin Roles API Tests', () => { const url = getServerUrl() @@ -101,4 +102,102 @@ describe('Admin Roles API Tests', () => { await dbAdminRole.deleteRoleByClientRolename(clientId, roleName) }) }) + + describe('GET:/roles', () => { + const getRoles = async (queryParam = '', header = requestHeaders) => { + return await fetch(`${url}/roles${queryParam}`, { + method: 'GET', + headers: header, + }) + } + + it('should return 200 and list of all roles', async () => { + const response = await getRoles() + assert.equal(response.status, 200) + const { data: roles } = await response.json() + assert.ok(roles?.length) + }) + + it('should return 200 and list of all roles for a client_id', async () => { + const client = await usherDb('clients').select('*').first() + const response = await getRoles(`?client_id=${client.client_id}`) + assert.equal(response.status, 200) + const { data: roles } = await response.json() + assert.ok(roles?.length) + assert.ok(roles.every(role => role.client_id === client.client_id)) + }) + + it('should return 200 and empty list of roles for invalid client_id', async () => { + const response = await getRoles(`?client_id=invalid`) + assert.equal(response.status, 200) + const { data: roles } = await response.json() + assert.ok(roles?.length === 0) + }) + + it('should return 200 and list of all roles which includes permissions', async () => { + const response = await getRoles('?include_permissions=true') + assert.equal(response.status, 200) + const { data: roles } = await response.json() + assert.ok(roles?.length) + assert.ok(roles.every(role => Array.isArray(role.permissions))) + }) + + it('should return 401 due to lack of proper token', async () => { + const userAccessToken = await getTestUser1IdPToken() + const response = await getRoles('', { + ...requestHeaders, + Authorization: `Bearer ${userAccessToken}` + }) + assert.equal(response.status, 401) + }) + }) + + describe('GET:/roles/{role_key}', () => { + let validRoleKey; + const getRole = async (roleKey, queryParam = '', header = requestHeaders) => { + return await fetch(`${url}/roles/${roleKey}${queryParam}`, { + method: 'GET', + headers: header, + }) + } + + before(async () => { + const role = await usherDb('roles').select('*').first() + validRoleKey = role.key + }) + + it('should return 200 and a role', async () => { + const response = await getRole(validRoleKey) + assert.equal(response.status, 200) + const role = await response.json() + assert.ok(role.key === validRoleKey) + }) + + it('should return 200 and a role which includes permissions', async () => { + const response = await getRole(validRoleKey, '?include_permissions=true') + assert.equal(response.status, 200) + const role = await response.json() + assert.ok(role.key === validRoleKey) + assert.ok(Array.isArray(role.permissions)) + }) + + it('should return 400 for invalid role key', async () => { + const response = await getRole('invalid_role_key') + assert.equal(response.status, 400) + }) + + it('should return 401 due to lack of proper token', async () => { + const userAccessToken = await getTestUser1IdPToken() + const response = await getRole(validRoleKey, '', { + ...requestHeaders, + Authorization: `Bearer ${userAccessToken}` + }) + assert.equal(response.status, 401) + }) + + it('should return 404 for nonexistence role key', async () => { + const response = await getRole(100000) + assert.equal(response.status, 404) + }) + }) }) diff --git a/server/the-usher-openapi-spec.yaml b/server/the-usher-openapi-spec.yaml index 889e800..72eb7cd 100644 --- a/server/the-usher-openapi-spec.yaml +++ b/server/the-usher-openapi-spec.yaml @@ -9,7 +9,7 @@ info: license: name: MIT url: https://opensource.org/licenses/MIT - version: 2.2.0 + version: 2.3.0 externalDocs: description: GitHub Repository url: https://github.com/DMGT-TECH/the-usher-server @@ -327,13 +327,14 @@ paths: - bearerAdminAuth: [] parameters: - $ref: '#/components/parameters/clientIdQueryParam' + - $ref: '#/components/parameters/includePermissionsQueryParam' responses: 200: description: The List of Roles content: application/json: schema: - $ref: '#/components/schemas/CollectionOfRoles' + $ref: '#/components/schemas/CollectionOfRolesWithPermissions' 400: $ref: '#/components/responses/BadRequest' post: @@ -381,13 +382,15 @@ paths: - Admin APIs security: - bearerAdminAuth: [] + parameters: + - $ref: '#/components/parameters/includePermissionsQueryParam' responses: 200: description: Return a Role for the given key content: application/json: schema: - $ref: '#/components/schemas/Role' + $ref: '#/components/schemas/RoleWithPermissions' 404: $ref: '#/components/responses/NotFound' patch: @@ -445,9 +448,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/PermissionObject" + $ref: "#/components/schemas/ArrayOfPermissionObject" 400: $ref: '#/components/responses/BadRequest' 401: @@ -456,6 +457,8 @@ paths: $ref: '#/components/responses/NotFound' 500: $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' /personas: get: @@ -536,6 +539,8 @@ paths: $ref: '#/components/responses/Unauthorized' 500: $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' post: 'x-swagger-router-controller': 'personas/persona' summary: Create a single persona @@ -575,6 +580,8 @@ paths: $ref: '#/components/responses/Conflict' 500: $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' /personas/{persona_key}: get: @@ -603,6 +610,8 @@ paths: $ref: '#/components/responses/NotFound' 500: $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' delete: 'x-swagger-router-controller': 'personas/persona' operationId: deletePersona @@ -624,6 +633,8 @@ paths: $ref: '#/components/responses/NotFound' 500: $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' /personas/{persona_key}/permissions: get: @@ -642,15 +653,15 @@ paths: content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/PermissionObject" + $ref: "#/components/schemas/ArrayOfPermissionObject" 401: $ref: '#/components/responses/Unauthorized' 404: $ref: '#/components/responses/NotFound' 500: $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' put: 'x-swagger-router-controller': 'personas/permissions' operationId: createPersonaPermissions @@ -687,6 +698,8 @@ paths: $ref: '#/components/responses/NotFound' 500: $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' /personas/{persona_key}/permissions/{permission_key}: delete: @@ -711,6 +724,8 @@ paths: $ref: '#/components/responses/NotFound' 500: $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' /personas/{persona_key}/roles: get: @@ -718,7 +733,8 @@ paths: operationId: getPersonaRoles summary: Get a list of roles assigned to the given Persona parameters: - - $ref: '#/components/parameters/personaKeyPathParam' + - $ref: '#/components/parameters/personaKeyPathParam' + - $ref: '#/components/parameters/includePermissionsQueryParam' tags: - Admin APIs security: @@ -731,13 +747,15 @@ paths: schema: type: array items: - $ref: "#/components/schemas/Role" + $ref: "#/components/schemas/RoleWithPermissions" 401: $ref: '#/components/responses/Unauthorized' 404: $ref: '#/components/responses/NotFound' 500: $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' put: 'x-swagger-router-controller': 'personas/roles' operationId: createPersonaRoles @@ -774,6 +792,8 @@ paths: $ref: '#/components/responses/NotFound' 500: $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' /personas/{persona_key}/roles/{role_key}: delete: @@ -798,6 +818,8 @@ paths: $ref: '#/components/responses/NotFound' 500: $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' /clients: get: @@ -821,6 +843,8 @@ paths: $ref: '#/components/responses/Unauthorized' 500: $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' post: 'x-swagger-router-controller': 'clients/index' @@ -933,10 +957,13 @@ paths: $ref: '#/components/responses/Conflict' 500: $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' /clients/{client_id}/roles: parameters: - $ref: '#/components/parameters/clientIdPathParam' + - $ref: '#/components/parameters/includePermissionsQueryParam' get: 'x-swagger-router-controller': 'clients/roles' operationId: listClientRoles @@ -952,9 +979,17 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/CollectionOfRoles' + $ref: '#/components/schemas/CollectionOfRolesWithPermissions' + 400: + $ref: '#/components/responses/BadRequest' + 401: + $ref: '#/components/responses/Unauthorized' 404: $ref: '#/components/responses/NotFound' + 500: + $ref: '#/components/responses/InternalError' + 503: + $ref: '#/components/responses/ServiceUnavailableError' post: 'x-swagger-router-controller': 'clients/roles' operationId: createClientRole @@ -1064,7 +1099,6 @@ components: value: the-usher Example 3 (all client applications where persona has a role): value: "*" - # client_id as query param clientIdQueryParam: name: client_id description: Unique identifier for the client. @@ -1072,6 +1106,14 @@ components: required: false schema: $ref: '#/components/schemas/EntityNameDef' + includePermissionsQueryParam: + name: include_permissions + in: query + description: Includes permissions for each role + required: false + schema: + type: boolean + example: true roleKeyPathParam: name: role_key description: The unique role identifier @@ -1167,7 +1209,7 @@ components: $ref: '#/components/schemas/EntityNameDef' description: type: string - nullable: true # Can't use shared schema with nullable in 3.0.x + nullable: true maxLength: 100 required: - key @@ -1177,6 +1219,14 @@ components: key: 10 name: usher:admin clientkey: fake-client + + RoleWithPermissions: + allOf: + - $ref: '#/components/schemas/Role' + - type: object + properties: + permissions: + $ref: '#/components/schemas/ArrayOfPermissionObject' CollectionOfRoles: type: object @@ -1186,6 +1236,14 @@ components: items: $ref: '#/components/schemas/Role' + CollectionOfRolesWithPermissions: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/RoleWithPermissions' + Permission: type: object properties: @@ -1211,6 +1269,12 @@ components: $ref: '#/components/schemas/EntityNameDef' description: $ref: '#/components/schemas/EntityDescriptionDef' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time required: - key - name @@ -1228,6 +1292,11 @@ components: items: $ref: '#/components/schemas/Permission' + ArrayOfPermissionObject: + type: array + items: + $ref: '#/components/schemas/PermissionObject' + DictionaryOfPermissions: type: object additionalProperties: false @@ -1584,6 +1653,16 @@ components: code: 500 message: Internal Error! + ServiceUnavailableError: + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: 503 + message: Service Unavailable! + Default: description: Unexpected error content: diff --git a/server/the-usher.js b/server/the-usher.js index 422701a..e810421 100644 --- a/server/the-usher.js +++ b/server/the-usher.js @@ -16,8 +16,8 @@ const { verifyTokenForAdmin, verifyTokenForSelf, verifyTokenForClientAdmin } = r const winstonLogger = require('./src/logging/winston-logger') // Normalizes a port into a number, string, or false -function normalizePort (val) { - var port = parseInt(val, 10) +const normalizePort = (val) => { + const port = parseInt(val, 10) if (isNaN(port)) { return val // named pipe } @@ -27,10 +27,15 @@ function normalizePort (val) { return false } -async function seedKeysIfDbIsEmpty () { - if ((await keystore.selectAllKeys()).length === 0) { - console.log('Note: There were no keys in the database generating and inserting a new key.') - keygen.generateAndInsertNewKeys() +const seedKeysIfDbIsEmpty = async () => { + try { + console.log('checking database for keys..') + if ((await keystore.selectAllKeys()).length === 0) { + console.log('Note: There were no keys in the database generating and inserting a new key.') + keygen.generateAndInsertNewKeys() + } + } catch (err) { + console.log(`Failed to seedKeysIfDbIsEmpty: ${JSON.stringify(err)}`) } } @@ -54,7 +59,7 @@ const optionsObject = { customErrorHandling: true } -function preInitCheck () { +const preInitCheck = () => { let missingKeyEnvVars = false if (!env.ISSUER_WHITELIST) { missingKeyEnvVars = true @@ -71,7 +76,7 @@ expressApp.use(usherCors()) expressApp.use(winstonLogger) oasTools.configure(optionsObject) -oasTools.initialize(oasDoc, expressApp, function () { +oasTools.initialize(oasDoc, expressApp, () => { const exitBeforeInitialization = preInitCheck() if (exitBeforeInitialization) { console.log('TheUsher is not initializing because critical env vars are not configured.') @@ -79,7 +84,7 @@ oasTools.initialize(oasDoc, expressApp, function () { process.exit(1) } const port = normalizePort(process.env.PORT || '3001') - http.createServer(expressApp).listen(port, function () { + http.createServer(expressApp).listen(port, () => { console.log('App up and running!') }) }) @@ -93,7 +98,7 @@ expressApp.use((err, req, res, next) => { next(err) }) -expressApp.use(function (err, req, res, next) { +expressApp.use((err, req, res, next) => { // handle case if headers have already been sent to client if (res.headersSent) { return next(err) @@ -107,7 +112,7 @@ expressApp.use(function (err, req, res, next) { }) // Default route to handle not found endpoints but return 405 for security -expressApp.use(function (req, res, next) { +expressApp.use((req, res, next) => { const notFoundResponse = { code: 405, message: 'Method Not Allowed' @@ -115,7 +120,10 @@ expressApp.use(function (req, res, next) { res.status(405).send(notFoundResponse) }) -seedKeysIfDbIsEmpty() +console.log(`SKIP_KEYS_CHECK value: ${process.env.SKIP_KEYS_CHECK}`) +if (!process.env.SKIP_KEYS_CHECK) { + seedKeysIfDbIsEmpty() +} module.exports = { 'the-usher': expressApp } // For deploying to GCP // For deploying to AWS Lambda