/*
* Author: Vlad Seryakov vseryakov@gmail.com
* backendjs 2018
*/
/**
* @module api/acl
*/
const lib = require(__dirname + '/../lib');
const logger = require(__dirname + '/../logger');
const _public = [
"^/$",
"\\.htm$", "\\.html$",
"\\.ico$", "\\.gif$", "\\.png$", "\\.jpg$", "\\.jpeg$", "\\.svg$",
"\\.ttf$", "\\.eot$", "\\.woff$", "\\.woff2$",
"\\.js$", "\\.css$",
"^/js/",
"^/css/",
"^/img",
"^/webfonts/",
"^/public/",
"^/ping",
];
const mod =
/**
* ACL for access authorization.
*
* Each ACL is a list of RegExps with a name.
*
* ACLs are grouped by a role, at least one must match in order to succeed.
*
* ### The are 3 predefned ACLS:
* - **public** - list of files and endpoints to allow access without authentication, default public endpoints are:
* ```
* ^/$, .htm$, .html$, .ico$, .gif$, .png$, .jpg$, .jpeg$, .svg$, .ttf$, .eot$, .woff$, .woff2$, .js$, .css$,
* ^/js/, ^/css/, ^/img, ^/webfonts/, ^/public/, ^/ping
* ```
* - **authenticated** - only authenticated user can access such endpoints
* - **anonymous** - same as public but still goes thru authentication to get current user if provided
*
* ### To define an ACL named **test** with endpoints and allow it for user bit not intern roles:
* ```
* api-acl-add-test = /url1|/url2...
*
* api-acl-allow-user = test
* api-acl-deny-intern = test
* ```
* The user and intern roles are defined in the {@link DbUser} table, see {@link module:api/users}
*
* @example <caption>make everything public</caption>
* api-acl-add-public = ^/
*
* @example <caption>all authenticated users can access /auth endpoint</caption>
* api-acl-authenticated = auth
* api-acl-add-auth = ^/auth
*
* @example <caption>only admins can access /admin endpoint</caption>
* api-acl-allow-admin = auth, admins
* api-acl-add-admins = ^/admin
*
* @Example <caption>users can access /users but not /users/billing</caption>
* api-acl-allow-user = auth, users, -users_deny
* api-acl-add-users = ^/user
*
* api-acl-add-users_deny = ^/user/billing
*
*/
module.exports = {
name: "api.acl",
args: [
{ name: "err-(.+)", descr: "Error messages for various cases" },
{ name: "add-([a-z0-9_]+)", type: "regexpobj", obj: "acl", make: "$1", descr: "Add URLs to the named ACL which can be used in allow/deny rules per role", example: "-api-acl-add-admins ^/admin" },
{ name: "deny-([a-z0-9_]+)", type: "list", obj: "deny", array: 1, sort: 1, descr: "Match all regexps from the specified acls to deny access for the specified role", example: "-api-acl-deny-user admins,billing" },
{ name: "allow-([a-z0-9_]+)", type: "list", obj: "allow", array: 1, sort: 1, descr: "Match all regexps from the specified acls for allow access for the specified role", example: "-api-acl-allow-staff admins,support,-billing" },
{ name: "public", type: "list", array: 1, sort: 1, descr: "Match all regexps from the specified acls for public access", example: "-api-acl-public pub,docs,-intdocs" },
{ name: "anonymous", type: "list", array: 1, sort: 1, descr: "Match all regexps from the specified acls to allow access with or without authentication", example: "-api-acl-anonymous pub,docs" },
{ name: "authenticated", type: "list", array: 1, sort: 1, descr: "Match all regexps from the specified acls to allow access only with authentication any role", example: "-api-acl-authenticated stats,profile" },
{ name: "reset", type: "callback", callback: function(v) { if (v) this.reset() }, descr: "Reset all rules" },
],
allow: {},
deny: {},
acl: {},
errDeny: "Access denied",
};
/**
* Reset all acls
* @memberof module:api/acl
* @method reset
*/
mod.reset = function()
{
this.acl = {
public: lib.toRegexpObj(null, _public)
};
this.allow = {};
this.deny = {};
this.public = ["public"];
this.authenticated = this.anonymous = null;
}
mod.reset();
/**
* Check the path agains given ACL list, if an ACL starts with `-` it means negative match, the check fails immediately
* @param {string} path
* @param {object[]} acls
* @returns {boolean}
* @memberof module:api/acl
* @method isMatched
*/
mod.isMatched = function(path, acls)
{
if (!path || !Array.isArray(acls)) return;
for (const acl of acls) {
if (typeof acl != "string") continue;
if (lib.testRegexpObj(path, mod.acl[acl[0] == "-" ? acl.substr(1) : acl])) {
return acl[0] != "-";
}
}
}
/**
* For the current user check allowed ACLs
* return true if matched
* @param {Request} req
* @returns {boolean}
* @memberof module:api/acl
* @method isAllowed
*/
mod.isAllowed = function(req)
{
for (const i in req.user?.roles) {
var p = req.user.roles[i];
if (this.isMatched(req.options.path, this.allow[p])) {
logger.debug("isAllowed:", this.name, 403, req.options, p, this.allow[p]);
return true;
}
}
logger.debug("isAllowed:", this.name, 403, req.options, "nomatch", req.user?.roles);
}
/**
* For the current user check not-allowed ACLs
* return true if matched
* @param {Request} req
* @returns {boolean}
* @memberof module:api/acl
* @method isDenied
*/
mod.isDenied = function(req)
{
for (const i in req.user?.roles) {
var p = req.user.roles[i];
if (this.isMatched(req.options.path, this.deny[p])) {
logger.debug("isDenied:", this.name, 403, req.options, p, this.deny[p]);
return true;
}
}
}
/**
* Returns true if the current request is allowed for public access
* @param {Request} req
* @return {boolean}
* @memberof module:api/acl
* @method isPublic
*/
mod.isPublic = function(req)
{
if (this.isMatched(req.options.path, this.public)) return true;
logger.debug("isPublic:", this.name, 403, req.options, this.public);
}
/**
* Returns true if the current request is must be authenticated
* @param {Request} req
* @return {boolean}
* @memberof module:api/acl
* @method isAuthenticated
*/
mod.isAuthenticated = function(req)
{
if (req.user?.id && this.isMatched(req.options.path, this.authenticated)) return true;
logger.debug("isAuthenticated:", this.name, 403, req.options, this.authenticated);
}
/**
* Returns true if the current request is allowed for public or authenticated access
* @param {Request} req
* @return {boolean}
* @memberof module:api/acl
* @method isAnonymous
*/
mod.isAnonymous = function(req)
{
if (this.isMatched(req.options.path, this.anonymous)) return true;
logger.debug("isAnonymous:", this.name, 403, req.options, this.anonymous);
}