This library allows you to secure methods on classes.
Its fundamental capability is to be able to answer security questions like "Can tellers close accounts?", assuming closing an account means to call the close() method on an instance of class Account.
This is known as role-based access control (RBAC), more specifically, role type-based access control, where static types of roles are used to determine access, as opposed to role instances.
The following describes the relationship between the fundamental concepts in security (principal, securable, action, and access control entry) and their relationship to the concepts implemented in this library.
There are four elements required in the determination of access:
- principal: The actor, user or system attempting to perform some action on a securable.
- securable: The thing being secured.
- action: The action being performed with respect to a securable.
- access control entry: the binding of the principal, securable and action together along with the "permitted" (or "denied") boolean, or some other access control strategy.
An optional fifth element is contextual data at the point of access control.
Here are the mappings to those fundamental concepts in this library.
- principal: a role name as a atring. This is also known as a scope in some security descriptions.
- securable: a method on a class.
- action: the particular method being invoked at the time of access determination.
- access control entry: the security policy given to the constructor of the
MethodAccessController.
This module exports a class called MethodAccessController.
It takes in its constructor a SecurityPolicy, which is an array of SecurityPolicyEntry objects.
A SecurityPolicyEntry is an object that looks like this:
const entry = {
roles: /role names regex/,
classes: /class names regex/,
methods: /method names regex/,
strategy: true // values are true, false, a function, or a string
}- If the
strategyis the boolean literaltrue, then access is permitted. - If the
strategyis the boolean literalfalse, then access is expicitly denied. - If the
strategyis afunction, then it must be of the form({role, clazz, method, data}): booleanand will be invoked with the current role name, class name, method name and the contextual data, respectively. - If the
strategyis astring, then it isrequire()ed, expected to return afunctionin form above, and invoked.
A single denial vetoes all permissions, and the absence of any permissions results in denial.
This class can be used standalone, but it's really intended to be used in conjunction with a decorator or an AOP implementation (shameless plug & tip-o-the-hat to @northscaler/aspectify) that can intercept method invocations with before advice.
In this manner, access control decisions can be made if user information is available to the decorator or to the advice that delegates to a MethodAccessController.
You might consider using @northscaler/continuation-local-storage to put said user information into such a place.
It's not really recommended, but in the absence of AOP, you'd use it in the following ways.
const context = require('...') // some means to get user from JWT or whatever
class Account {
constructor(id, balance, /* ... */) {
this.id = id
this.balance = balance
// ...
this.controller = new MethodAccessController(require('./account-rbac-policy.json'))
}
// ...
close() {
// begin security check
const roles = context.user?.roles
if (roles && !roles.map(role => this.controller.grants({
role,
clazz: 'Account',
method: 'close',
data: this
})).includes(true)) {
const e = new Error('E_ACCESS_DENIED')
e.context = { user: context.user, roles: context.user?.roles, clazz: 'Account', method: 'close'}
throw e
}
// end security check
this.open = false // or whatever
}
}You can use custom logic in your access control strategies.
Here's one that only lets MANAGERs close big Accounts.
const strategy = it => it.role === 'MANAGER' || it.data?.balance < 10000
// the above strategy assumes data is the Account instance
class Account {
constructor(id, balance, /* ... */) {
this.id = id
this.balance = balance
// ...
this.controller = new MethodAccessController([{
classes: /^Account$/,
methods: /^close$/,
roles: /^.+$/,
strategy
}])
}
// ...
close() {
// begin security check
const roles = context.user?.roles
if (roles && !roles.map(role => this.controller.grants({
role,
clazz: 'Account',
method: 'close',
data: this // this allows the strategy to check the account's balance
})).includes(true)) {
const e = new Error('E_ACCESS_DENIED')
e.context = { user: context.user, roles: context.user?.roles, clazz: 'Account', method: 'close'}
throw e
}
// end security check
this.open = false // or whatever
}
}