Source: index.js

const msgpack = require('msgpack5');
const _ = require('lodash');
const { encode, decode } = msgpack();

/**
 * Defines and checks permissions with claims written in Apache Shiro style
 * and use a Trie data structure for performance
 */
class ShiroPerms {
  /**
   * Creates new ShiroPerms object from a list of claims
   * @param  {...String|String[]} claims Claims list
   * @return {ShiroPerms}
   */
  static from(...claims) {
    let allClaims = [];
    claims.forEach((claim) => {
      if (typeof claim === 'string') {
        allClaims.push(...claim.split(' '));
      } else {
        allClaims.push(...claim);
      }
    });

    return new ShiroPerms(allClaims);
  }

  /**
   * Creates new ShiroPerms object from a Trie object
   * @param  {Object|String} [trie={}] Trie object or JSON
   * @return {ShiroPerms}
   */
  static fromTrie(trie = {}) {
    const shiroPerms = new ShiroPerms();
    return shiroPerms.load(trie);
  }

  /**
   * Creates new ShiroPerms object from a Trie object in bin format (msgpack5)
   * @param  {Buffer} data
   * @return {ShiroPerms}
   */
  static fromBin(data) {
    const shiroPerms = new ShiroPerms();
    return shiroPerms.loadBin(data);
  }

  /**
   * Creates a new ShiroPerms instance with specified claims.
   * Claims may be added or removed later.
   *
   * @param  {String[]}   [claims=[]] Claims to create the object with.
   * @return {ShiroPerms}
   */
  constructor(claims = []) {
    this._trie = {};
    this.claims = claims;
  }

  /////////////////////////
  // Instance Properties //
  /////////////////////////

  /**
   * A list of current claims
   * @type {String[]}
   */
  get claims() {
    return this._claims();
  }

  set claims(claims) {
    this.reset();
    this.add(claims);
  }

  /**
   * Returns current permissions Trie object
   * @type {object}
   */
  get trie() {
    return this._trie;
  }

  //////////////////////
  // Instance Methods //
  //////////////////////

  /**
   * Removes all permissions from the Trie
   * @return {ShiroPerms} this
   */
  reset() {
    this._trie = {};
    return this;
  }

  /**
   * Add claims to permission Trie
   * Accept multiple claim string with claims separated by space char ' '
   * @example
   * // Single claim
   * perms.add('store:view');
   *
   * // Claim array
   * perms.add(['store:view', 'store:edit:1234']);
   *
   * // Mutiple claim string
   * perms.add('store:view store:edit:1234');
   *
   * @param   {String|String[]} [claims=[]]
   * @return  {ShiroPerms}
   */
  add(claims = []) {
    const claimList = typeof claims === 'string' ? [...claims.split(' ')] : claims;
    claimList.forEach(claim =>
      this._insertChild(this._trie, claim.split(':'))
    );
    return this;
  }

  /**
   * Remove claims from the Trie.
   * Claims in Shiro compact format are not allowed, e.g. 'store:view,edit'.
   * Accept multiple claim string with claims separated by space char ' '
   * @param  {String|String[]} [claims=[]]
   * @return {ShiroPerms}
   */
  remove(claims = []) {
    const claimList = typeof claims === 'string' ?
      claims.split(' ') :
      claims;

    this.claims = this.claims.filter(claim =>
      !claimList.find(remove =>
        (remove === claim || remove + ':*' === claim)
      )
    );
    return this;
  }

  /**
   * Verify permissions against current Trie.
   * Multiple permissions input may be compared using AND/OR logic operator
   * controlled by 'any' parameter.
   * Permission in Shiro compact format are not allowed, e.g. 'store:view,edit'.
   * Accept multiple claim string with claims separated by space char ' '
   *
   * @param  {String|String[]} [permissions=[]] Permisssions to be checked
   * @param  {Boolean}         [any=false] If true, checks for any permission (OR).
   * Checks all permissions otherwise (AND)
   * @return {Boolean} Allowed
   */
  check(permissions = [], any = false) {
    const permsList = typeof permissions === 'string' ?
      permissions.split(' ') :
      permissions;

    const verify = perm => this._check(this._trie, perm.split(':'));

    return (
      !!Object.keys(this._trie).length &&
      (any ? permsList.some(verify) : permsList.every(verify))
    );
  }

  /**
   * Verify ANY permissions against current Trie.
   * Alias of 'check()' method with flag 'any' set to false.
   * Permission in Shiro compact format are not allowed, e.g. 'store:view,edit'.
   * Accept multiple claim string with claims separated by space char ' '
   * @param  {String|String[]} [permissions=[]] Permisssions to be checked
   * @return {Boolean} Allowed
   */
  checkAny(permissions = []) {
    return this.check(permissions, true);
  }

  /**
   * Print current claims in single string format
   * @return {String}
   */
  toString() {
    return this._compactClaims().join(' ');
  }

  /**
   * Set internal Trie object. Useful to import permission dumps or
   * an external Trie object.
   * Accept Trie object in JSON format
   * @param  {Object|String} [trie={}] Trie object or JSON string to load
   * @return {ShiroPerms} this
   */
  load(trie = {}) {
    try {
      this._trie = typeof trie === 'string' ?
      JSON.parse(trie) :
      { ...trie };
    } catch (e) {
      this._trie = {};
    }

    return this;
  }

  /**
   * Dumps current Trie to JSON.
   * @return {String} Trie in JSON format
   */
  dump() {
    return JSON.stringify(this._trie);
  }

  /**
   * Dumps current Trie to bin format (msgpack5).
   * @return {Buffer}
   */
  dumpBin() {
    return encode(this._trie);
  }

  /**
   * Loads Trie from a bin format data (msgpack5)
   * @param  {Buffer} data Data to load
   * @return {ShiroPerms}
   */
  loadBin(data) {
    try {
      this._trie = decode(data);
    } catch (e) {
      this._trie = {};
    }
    return this;
  }

  //////////////////////
  // Internal Methods //
  //////////////////////

  /**
   * Method to add a branch in a node of the Trie.
   * This discards or overwrite redudant branches.
   * @private
   * @param  {Object}   node          Root node
   * @param  {String[]} [elements=[]] Child nodes elements
   * @param  {Object}   [parentNode] Parent trie node
   * @param  {String}   [parentTerm] Parent trie node term
   */
  _insertChild(node, elements = [], parentNode, parentTerm) {
    const parts = [ ...elements ];
    const part = parts.shift();

    if (part) {
      part.split(',').forEach((term) => {
        // Check redundacy
        if (
          node['*'] &&
          ( !Object.keys(node['*']).length || !!node['*'][parts[0]] )
        ) {
          // discard since new terms are redundant
          return;
        }

        // Deals with last part of new claim
        if (!parts.length) {
          if (term === '*') {
            // overwrite parent node or the whole trie
            parentTerm ?
              parentNode[parentTerm] = { '*': {} } :
              this._trie = { '*': {} };
            return;
          }
          // last part. add * term if not itself and
          // overwrite child node
          node[term]= { '*': {} };
          return;
        }

        // Deals with middle terms in new claim

        // Adding a '*' in the middle
        if (term === '*') {
          // discard already existing redundant terms in the trie
          const newTerms = {};
          Object.keys(node).forEach((key) => {
            Object.entries(node[key]).forEach(([subKey, val]) => {
              if (subKey !== parts[0]) {
                newTerms[key] = {};
                newTerms[key][subKey] = val;
              }
            })
          });

          newTerms['*'] = node['*'] || {};
          // 'node' assignment is required to overwrite also the current
          // processing node
          parentNode[parentTerm] = node = newTerms;
        }

        // Adding other than '*' terms
        if (!node[term]) {
          // adds new term
          node[term] = {};
        }

        this._insertChild(node[term], parts, node, term);
      });
    }
  }

  /**
   * Method to recursively transverse the Trie and check a permission.
   * @private
   * @param  {Object}   node          Root node
   * @param  {String[]} [elements=[]] Child nodes elements
   * @return {Boolean}                Found
   */
  _check(node, elements = []) {
    if (!node) return false;

    const parts = [ ...elements ];
    const part = parts.shift();
    if (part && node[part] || node['*']) {
      // Within a matching part of a permission statement
      if (parts.length > 0) {
        // Not the last part then check another level
        return (
          this._check(node[part], parts) ||
          part !== '*' && this._check(node['*'], parts)
        );
      }
      // Last part of permission
      if (part !== '*' && !node['*']) {
        // last part is not a *. Need to test another level - implicit *
        return this._check(node[part], ['*']);
      }
      return true;
    }

    // No matching part in the permission
    // If node is empty we've reached one end of the trie -
    // i.e full match. Else no match.
    return !Object.keys(node).length;
  }

  /**
   * Method used to transverse the trie and produce a list of claims.
   * Reversed option is used internally to produce a compact list of claims.
   * @param  {Boolean} [reversed=false] If true, claims are output in backwards
   * @return {String[]} List of claims present in the trie
   */
  _claims(reversed = false) {
    const allClaims = [];
    // Internal method to flat the trie into an array of strings
    const reduce = (claim, node) => {
      const claims = Object.keys(node);
      if (!claims.length) {
        allClaims.push(!reversed ? claim : claim.slice(2) || claim);
        return;
      }
      claims.forEach((part) => {
        reduce(
          !reversed ? `${claim}:${part}` : `${part}:${claim}`,
          node[part]
        );
      });
    };

    Object.keys(this._trie).forEach((part) => {
      reduce(part, this._trie[part]);
    });
    return allClaims;
  }

  _compactClaims() {
    // List of inverted claims
    const _reverse = this._claims(true);
    const _inverted = ShiroPerms.from(_reverse);

    _inverted._trie = compress(_inverted.trie);
    return _inverted._claims(true);
  }
}

/**
 * Recursive method to compute a compressed trie object.
 * The resulting trie is not suitable for permission checks.
 * Used internally to produce a compact string representation of
 * claims in the trie.
 * @private
 * @param  {Object} node Root trie node
 * @return {Object} New trie object
 */
function compress(node) {
  const _out = {};
  let _keys = Object.keys(node);

  while (_keys.length > 0) {
    // Working key
    const _key = _keys.pop();
    // Keys that will be written to the output
    const _commonKeys = [_key];
    // Key left for next iteration
    const _nextKeys = [];

    // Compare with other keys
    _keys.forEach((_otherKey) => {
      if (_.isEqual(node[_key], node[_otherKey])) {
        // Redudant. Can be compacted
        _commonKeys.push(_otherKey);
      } else {
        // Left for next iteration
        _nextKeys.push(_otherKey);
      }
    });

    // Write common key
    _out[_commonKeys.join(',')] = compress(node[_key]);

    // Prepare next iteration
    _keys = _nextKeys;
  }

  return _out;
}

module.exports = ShiroPerms;