@yanfoo/rbac-a

RBAC-A/ABAC dynamic plugin roles implementation

Usage no npm install needed!

<script type="module">
  import yanfooRbacA from 'https://cdn.skypack.dev/@yanfoo/rbac-a';
</script>

README

RBAC-A (ABAC)

npm version

Role Based Access Control with Attributes and dynamic plugin roles implementation. This module follows the NIST RBAC model and offer a flexible solution to allow or restrict user operations.

Introduction

In an RBAC system, permissions are assigned to roles, not users. Therefore, roles act as a ternary relation between permissions and users. Permissions are static, defined in the applications. Roles, on the other hand, are dynamic and can be defined from an application interface (API), or user interface (UI), and saved in a datastore.

This module is not dependent on an authentication, a user session, or a datastore system. The relation between the user and it's roles are specified by a Provider. It is the application's responsibility to implement such provider. See providers for more information.

Rules are applied in consideration with the roles hierarchy. Top level roles always have priority over inherited roles. When validating users against given permissions, the best role priority matching the permissions is returned. Therefore, "allowed" users will always resolve with a positive integer, and "restricted" users will always resolve with a non-numeric value (i.e. NaN). See usage for more information, or how to restrict users with this module.

Usage

import RBAC, { JsonRBACProvider, RBACValidationError } from '@yanfoo/rbac-a';
import permissionData from './permission-data.json';
/*
permissionData = {
  roles: {
    editor: {
      permissions: [
        { can: 'create' },
        { can: 'edit' }   // no restriction from attributes
      ],
      inherited: [ 'contributor' ]
    },
    contributor: {
      permissions: [
        { can: 'edit', when: { timeInterval: { from: "8:00", to: "17:00" } } }
      ],
      inherited: [ 'authenticatedUser' ]
    },
    authenticatedUser: {
      permissions: [
        { can: 'login' }
      ]
    }
  },
  users: {
    1: ['editor'],
    2: ['contributor'],
    3: ['authenticatedUser']
  }
}
*/


const rbac = new RBAC({
  provider: new JsonRBACProvider(permissionData),   // mandatory
  attributes: {                                     // optional
    timeInterval: ({ from, to }) => checkCurrentTimeBetween(from, to)
  },
  checkOptions: {
    onError: err => {                                 // optional
      if (err instanceof RBACValidationError) {
        console.error('Error while checking %s with user roles %s', 
          JSON.stringify(err.user), 
          JSON.stringify(err.role),
          err
        );
      } else {
        console.error(err);
      }
    },
    // onSuccess: (role, level) => ...
    // ignoreMissingAttributes: false
  }
});


// check permission for user #3
await rbac.check(3, 'login');
// -> 0 (the access level)

// check double permissions for user #2
await rbac.check(2, 'create, edit');
// -> NaN because a contributor can edit, but not create

// check double permissions for user #1 with some restrictions
await rbac.check(2, 'edit');
// -> 0 only if current time is between 8am and 5pm


The method rbac.check will resolve with a positive numeric value if the given user has access to the specified roles, or NaN otherwise.

If for ever reason the validation should fail, either at the provider level, or during an attribute validation, then the method will return NaN.

Definitions

Users

When invoking rbac.check, the argument user is an arbitrary value that is only checked within the specified providers. For this reason, the value should normally be numeric or string, however it may very well be an Object. Whatever the value, it should be considered immutable at all times. Check the provider implementation for more information.

Roles

A role is an organizational unit defining a group of permissions assignable to users. Roles consitute de bridge between actual permissions and users. Roles are hierarchichal, meaning that they may have a child to parent relationship.

Attributes

Attributes are used to conditionally authorize certain roles' permissions. For example, an attribute that would dynamically check the user's device and enable the permission depending on the device currently in use. This is useful, for example, to grant users with login permissions only at very specific times depending on their roles.

Since attributes are evaluated, they are provided as option parameters in the role definitions, and as asynchronous callbacks at the provider level. This not only allows making roles persistent in a database or a cache (i.e. preventing code injection), but also makes the same attributes reusable by various roles with different parameters.

const rbac = new RBAC({
  provider,
  attributes: {
    syncAttrib: options => { /* ... */ },
    asyncAttrib: async options => { /* ... */ }
  }
});

The above defines two attributes, called syncAttrib and asyncAttrib respectively.

For more control over attribute options, or if attributes should be stateful, consider using an implementation of RBACAttribute. For example :

import RBAC, { RBACAttribute } from '@yanfoo/rbac-a';
import users from '/path/to/models/users';
import permissionData from './permission-data.json';
/* permissionData = 
{
  roles: {
    authenticatedUser: {
      permissions: [{ can: 'login', when: { userActive: true }}]
    }
  }
  users: {
    1: ['authenticatedUser']
  }
}
*/

class UserActiveAttribute extends RBACAttribute {
  async check(isActive, user) {
    const userActive = await users.isActive(user);

    return userActive === isActive;
  }
}

const rbac = new RBAC({
  provider: new JsonRBACProvider(permissionData),
  attributes: {
    userActive: new UserActiveAttribute()
  },
  ...
});

await rbac.check(1, 'login');
// will call the attribute 'userActive' with the arguments `true` and `1`

When multiple attributes are specified for a role permission, all must be valid for the permission to be valid.

Data relations

  +--------+ 1.   n. +--------+ 1.   n. +--------------+
  |  User  |---------|  Role  |---------|  Permission  |
  +--------+         +--------+         +--------------+
                                            1. |    n.
                                               +------[ Attribute ]

API

RBAC

interface RBACOptions {
  provider:RBACProvider, 
  attributes:Object<String,Function> | Map<String,Function>, 
  options:Object = {}
}

interface RBACProviderOptions {}

interface RBACCheckSuccessEvent {
  user:Any,
  role:String,
  level:Number
}

interface RBACCheckOptions {
  ignoreMissingAttributes:Boolean,
  providerOptions:RBACProviderOptions,
  onError:Function(err:RBACValidationError),
  onSuccess:Function(e:RBACCheckSuccessEvent)
}

interface Role {
  role:String
  level:Number
  permissions:Array<Permission> | Set<Permission>
}

interface Permission {
  // the permission strings
  can:String | Array<String> | Set<String>
  // the attributes
  when:Map<String,Any> | Object<String,Any>
}

class RBAC {
  /**
   * Create a new instance of RBAC
   */
  constructor(options:RBACOptions)

  /**
   * Validate the given user
   */
  async check(
    user:Any,
    permissions:String | Array<String> | Set<String>,
    options:RBACCheckOptions = {}
  ):Promise<Number|NaN>
}


interface RBACProvider {
  /**
   * Retrive all permission rules to validate users against
   **/
  async getRoles(user:Any, options:RBACProviderOptions):Promise<Array<Role> | Set<Role>>
}

Custom RBACProvider

While the above example might work well when roles are static, it might be necessary for an application to implement their own providers, for example, when roles are managed externally. In the JsonRBACProvider implementation, roles are specified separate from users, so they aren't immediately compatible with how the RBAC instance consume them. The RBACProvider is responsible to provider only the roles for a given user.

Consider, in the example below, the input and output of the provider :

const provider = new JsonRBACProvider({
  roles: {
    editor: {
      permissions: [
        { can: 'create' },
        { can: 'edit' }   // no restriction from attributes
      ],
      inherited: [ 'contributor' ]
    },
    contributor: {
      permissions: [
        { can: 'edit', when: { timeInterval: { from: "8:00", to: "17:00" } } }
      ],
      inherited: [ 'authenticatedUser' ]
    },
    authenticatedUser: {
      permissions: [
        { can: 'login' }
      ]
    }
  },
  users: {
    1: ['editor'],
    2: ['contributor'],
    3: ['authenticatedUser']
  }
});

const userRoles1 = await provider.getRoles(1);
/* [
  { role: 'editor', level: 0, permissions: [
    { can: 'create' },
    { can: 'edit' }
  ]},
  { role: 'contributor', level: 1, permissions: [
    { can: 'edit', when: { timeInterval: { from: "8:00", to: "17:00" } } }
  ]},
  { role: 'authenticatedUser', level: 2, permissions: [
    { can: 'login' }
  ]}
] */

const userRoles2 = await provider.getRoles(2);
/* [
  { role: 'contributor', level: 0, permissions: [
    { can: 'edit', when: { timeInterval: { from: "8:00", to: "17:00" } } }
  ]},
  { role: 'authenticatedUser', level: 1, permissions: [
    { can: 'login' }
  ]}
] */

const userRoles4 = await provider.getRoles(4);  // no known user
/* [] */

Each user has their own level value per role because, even if all belong to the same roles, not all have the same associated role hierarchy; some users may have more privileges within the same roles. In the above example, user 1 is not restricted to the edit permission compared to user 2, who has a time restriction, allowed only between "8:00" and "17:00".

The following would be a custom RBACProvider fetching user roles from a database :

class RemoteRBACProvider extends RBACProvider {

  constructor(db) {
    this.db = db;
  }

  // the server is responsible to return Array<Role>
  async getRoles(user/*, options */) {
    return user?.id ? db.fetchRoles(user.id) : [];
  }
}

If if the system does not need a hierarchy, and can cache roles :

// NOTE : the following code is for illustration only, it is neither optimal or ideal!
class RemoteRBACProvider extends RBACProvider {
  users = new Map();
  roles = new Map();

  constructor(db) {
    this.db = db;
  }

  // the server is responsible to return Array<Role>
  async getRoles(user/*, options */) {
    const result = [];
    let userRoles, role;

    if (users.has(user?.id)) {
      userRoles = users.get(user.id);
    } else if (user?.id) {
      userRoles = await db.fetchUserRoles(user.id) || [];
      users.set(user.id, userRoles);
    } else {
      userRoles = [];
    }
    // userRoles = ['role1', 'role2', ...];

    for (const userRole of userRoles) {
      if (roles.has(userRole)) {
        role = roles.get(userRole);
      } else {
        role = await db.fetchRole(userRole) || {}
        roles.set(userRole, role);
      }
      // role = { permissions: ['foo', 'bar', ...] }

      // NOTE : we omit the property `level`, it will default to 0 by the RBAC instance
      result.push({ role: userRole, permissions: role.permissions?.map(can => ({ can })) || [] });
    }

    return result;
    // userRoles = [{ role:'role1', permissions:[{ can:'foo' },{ can:'bar'}], ... }]
  }
}

TL;DR;

Check out the source code for more implementation information.

Contribution

All contributions welcome! Every PR must be accompanied by their associated unit tests!

License

The MIT License (MIT)

Copyright (c) 2015 Mind2Soft yanick.rochon@mind2soft.com

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.