From b526b8f24dc5588226faff8eae0ab8a892430a5a Mon Sep 17 00:00:00 2001 From: garrettmills Date: Wed, 20 May 2020 21:17:07 -0500 Subject: [PATCH] Add api_scope target for IAM policy --- TODO.text | 1 - .../app/resource/iam/Policy.resource.js | 13 ++++++++ app/controllers/api/v1/IAM.controller.js | 18 ++++++++-- app/controllers/api/v1/Reflect.controller.js | 7 +++- app/models/iam/Policy.model.js | 33 ++++++++++++++++--- .../middleware/api/Permission.middleware.js | 11 ++++++- 6 files changed, 72 insertions(+), 11 deletions(-) diff --git a/TODO.text b/TODO.text index dcb2d4a..a8e4b8a 100644 --- a/TODO.text +++ b/TODO.text @@ -10,7 +10,6 @@ - Cobalt form - after action handlers - e.g. after insert perform action - e.g. after update perform action, &c. -- IAM manage user API scopes - OAuth2 -> support refresh tokens - Traps -> support session traps; convert MFA challenge to use session trap - Allow setting user trap from web UI diff --git a/app/assets/app/resource/iam/Policy.resource.js b/app/assets/app/resource/iam/Policy.resource.js index 487f0b5..c7a9147 100644 --- a/app/assets/app/resource/iam/Policy.resource.js +++ b/app/assets/app/resource/iam/Policy.resource.js @@ -118,6 +118,7 @@ class PolicyResource extends CRUDBase { type: 'select', options: [ { display: 'Application', value: 'application' }, + { display: 'API Scope', value: 'api_scope' }, ], }, { @@ -132,6 +133,18 @@ class PolicyResource extends CRUDBase { }, if: (form_data) => form_data.target_type === 'application' }, + { + name: 'Target', + field: 'target_id', + required: true, + type: 'select.dynamic', + options: { + resource: 'reflect/Scope', + display: 'scope', + value: 'scope', + }, + if: (form_data) => form_data.target_type === 'api_scope' + }, ], } } diff --git a/app/controllers/api/v1/IAM.controller.js b/app/controllers/api/v1/IAM.controller.js index 7502764..3276e5f 100644 --- a/app/controllers/api/v1/IAM.controller.js +++ b/app/controllers/api/v1/IAM.controller.js @@ -2,7 +2,7 @@ const { Controller } = require('libflitter') class IAMController extends Controller { static get services() { - return [...super.services, 'models'] + return [...super.services, 'models', 'canon'] } async check_entity_access(req, res, next) { @@ -111,7 +111,7 @@ class IAMController extends Controller { .message('Invalid access_type. Must be one of: allow, deny.') .api() - if ( !['application'].includes(req.body.target_type) ) + if ( !['application', 'api_scope'].includes(req.body.target_type) ) return res.status(400) .message('Invalid target_type. Must be one of: application.') .api() @@ -124,6 +124,12 @@ class IAMController extends Controller { return res.status(400) .message('Invalid target_id.') .api() + } else if ( req.body.target_type === 'api_scope' ) { + const api_scopes = this.canon.get('controller::api:v1:Reflect.api_scopes')() + if ( !api_scopes.includes(req.body.target_id) ) + return res.status(400) + .message('Invalid target_id.') + .api() } const policy = new Policy({ @@ -189,7 +195,7 @@ class IAMController extends Controller { .message('Invalid access_type. Must be one of: allow, deny.') .api() - if ( !['application'].includes(req.body.target_type) ) + if ( !['application', 'api_scope'].includes(req.body.target_type) ) return res.status(400) .message('Invalid target_type. Must be one of: application.') .api() @@ -202,6 +208,12 @@ class IAMController extends Controller { return res.status(400) .message('Invalid target_id.') .api() + } else if ( req.body.target_type === 'api_scope' ) { + const api_scopes = this.canon.get('controller::api:v1:Reflect.api_scopes')() + if ( !api_scopes.includes(req.body.target_id) ) + return res.status(400) + .message('Invalid target_id.') + .api() } policy.entity_type = req.body.entity_type diff --git a/app/controllers/api/v1/Reflect.controller.js b/app/controllers/api/v1/Reflect.controller.js index 4fac23f..97c2ef3 100644 --- a/app/controllers/api/v1/Reflect.controller.js +++ b/app/controllers/api/v1/Reflect.controller.js @@ -130,7 +130,7 @@ class ReflectController extends Controller { return res.api() } - async get_scopes(req, res, next) { + api_scopes() { const routers = this.routers.canonical_items const scopes = [] @@ -158,6 +158,11 @@ class ReflectController extends Controller { } scopes.sort() + return scopes + } + + async get_scopes(req, res, next) { + const scopes = this.api_scopes() return res.api(scopes.map(x => { return { scope: x } })) diff --git a/app/models/iam/Policy.model.js b/app/models/iam/Policy.model.js index 3866eb3..b8e9529 100644 --- a/app/models/iam/Policy.model.js +++ b/app/models/iam/Policy.model.js @@ -12,7 +12,7 @@ class PolicyModel extends Model { entity_type: String, // user | group entity_id: String, access_type: String, // allow | deny - target_type: { type: String, default: 'application' }, // application + target_type: { type: String, default: 'application' }, // application | api_scope target_id: String, active: { type: Boolean, default: true }, } @@ -44,6 +44,27 @@ class PolicyModel extends Model { return (await this.check_allow(entity_id, target_id)) && !(await this.check_deny(entity_id, target_id)) } + static async check_user_denied(user, target_id) { + const groups = await user.groups() + const group_ids = groups.map(x => x.id) + + const user_denials = await this.find({ + entity_id: user.id, + target_id, + access_type: 'deny', + active: true, + }) + + const group_denials = await this.find({ + entity_id: { $in: group_ids }, + target_id, + access_type: 'deny', + active: true, + }) + + return user_denials.length > 0 || group_denials.length > 0 + } + static async check_user_access(user, target_id) { const groups = await user.groups() const group_ids = groups.map(x => x.id) @@ -51,28 +72,28 @@ class PolicyModel extends Model { const user_approvals = await this.find({ entity_id: user.id, target_id, - approval_type: 'allow', + access_type: 'allow', active: true, }) const user_denials = await this.find({ entity_id: user.id, target_id, - approval_type: 'deny', + access_type: 'deny', active: true, }) const group_approvals = await this.find({ entity_id: { $in: group_ids }, target_id, - approval_type: 'allow', + access_type: 'allow', active: true, }) const group_denials = await this.find({ entity_id: { $in: group_ids }, target_id, - approval_type: 'deny', + access_type: 'deny', active: true, }) @@ -109,6 +130,8 @@ class PolicyModel extends Model { const Application = this.models.get('Application') const app = await Application.findById(this.target_id) target_display = `Application: ${app.name}` + } else if ( this.target_type === 'api_scope' ) { + target_display = `API Scope: ${this.target_id}` } return { diff --git a/app/routing/middleware/api/Permission.middleware.js b/app/routing/middleware/api/Permission.middleware.js index 674bd2c..bc0f028 100644 --- a/app/routing/middleware/api/Permission.middleware.js +++ b/app/routing/middleware/api/Permission.middleware.js @@ -1,7 +1,13 @@ const { Middleware } = require('libflitter') class PermissionMiddleware extends Middleware { + static get services() { + return [...super.services, 'models'] + } + async test(req, res, next, { check }) { + const Policy = this.models.get('iam:Policy') + // If the request was authorized using an OAuth2 bearer token, // make sure the associated client has permission to access this endpoint. if ( req?.oauth?.client ) { @@ -11,8 +17,11 @@ class PermissionMiddleware extends Middleware { .api() } + const policy_denied = await Policy.check_user_denied(req.user, check) + const policy_access = await Policy.check_user_access(req.user, check) + // Make sure the user has permission - if ( !req.user.can(check) ) + if ( policy_denied || (!req.user.can(check) && !policy_access) ) return res.status(401) .message('Insufficient permissions.') .api()