mod_utils_roles.js

/**
 * Roles utility module for handling role-based access control and object merging
 * @module /utils/roles
 */

/**
 * @global
 * @typedef {Object} roles
 * @property {Object} roles - roles configuration object
 * @property {boolean} [roles.*] - Wildcard role indicating unrestricted access
 * @property {Object} [roles.key] - Role-specific properties to merge
 * @property {Object} [roles.'!key'] - Negated role properties (applied when user doesn't have the role)
 */

const merge = require('./merge')

module.exports = {
  check,
  objMerge
}

/**
 * Checks if an object should be accessible based on user roles
 * @param {Object} obj - The object to check access for
 * @param {roles} obj.roles - Role configuration object
 * @param {Array<string>} user_roles - Array of roles assigned to the user
 * @returns {(Object|boolean)} Returns the original object if access is granted, false otherwise
 * 
 * @example
 * // Object with unrestricted access
 * check({ roles: { '*': true }, data: 'content' }, ['user']) // returns object
 * 
 * // Object with role restriction
 * check({ roles: { admin: true }, data: 'content' }, ['user']) // returns false
 * check({ roles: { admin: true }, data: 'content' }, ['admin']) // returns object
 * 
 * // Object with negated roles
 * check({ roles: { '!guest': true }, data: 'content' }, ['guest']) // returns false
 * check({ roles: { '!guest': true }, data: 'content' }, ['user']) // returns object
 */
function check(obj, user_roles) {

  // The object to check has no roles assigned.
  if (!obj.roles) return obj;

  // Always return object with '*' asterisk role.
  if (Object.hasOwn(obj.roles, '*')) return obj;

  if (!user_roles) return false

  // Some negated role is included in user_roles[]
  const someNegatedRole = Object.keys(obj.roles).some(
    (role) => /^!/.exec(role) && user_roles.includes(role.replace(/^!/, ''))
  );

  if (someNegatedRole) return false;

  // Check whether every role is negated.
  const everyNegatedRoles = Object.keys(obj.roles).every((role) =>
    /^!/.exec(role)
  );

  if (everyNegatedRoles) return obj;

  // Some positive role is included in user_roles[]
  const somePositiveRole = Object.keys(obj.roles).some((role) =>
    user_roles.includes(role)
  );

  if (somePositiveRole) return obj;

  // The check fails by default.
  return false;
}

/**
@function objMerge

@description
Recursively merges role-specific object properties based on user roles.
The function handles several special cases:
- Recursively processes nested objects
- Handles arrays by mapping over their elements
- Processes negated roles (prefixed with '!')
- Preserves the original object if conditions aren't met
- Skip null or undefined values

```js
const obj = {
  name: 'layer',
  roles: {
    admin: { secretField: 'sensitive' },
    user: { publicField: 'visible' }
  }
};

// With admin role
objMerge(obj, ['admin']); 
// Returns: { name: 'layer', secretField: 'sensitive', roles: {...} }

// With user role
objMerge(obj, ['user']);
// Returns: { name: 'layer', publicField: 'visible', roles: {...} }
```
@param {Object} obj The object to process
@param {Array<string>} user_roles Array of roles assigned to the user
@property {roles} obj.roles Role configuration object
@returns {Object} Processed object with merged role-specific properties
*/
function objMerge(obj, user_roles) {

  if (typeof obj !== 'object') return obj;

  if (!Array.isArray(user_roles)) return obj

  if (Array.isArray(obj)) {

    return obj.map(arrEntry => objMerge(arrEntry, user_roles))
  }

  Object.keys(obj)
    .filter(key => typeof obj[key] === 'object')
    .forEach(key => {

      // Cannot convert undefined or null to object.
      if (!obj[key]) return;

      obj[key] = objMerge(obj[key], user_roles)
    })

  if (!obj.roles) return obj;

  if (typeof obj.roles !== 'object') return obj;

  if (Array.isArray(obj.roles)) return obj;

  if (typeof obj.roles === 'function') return obj;

  const clone = structuredClone(obj)

  Object.keys(clone.roles)
    .filter(role => clone.roles[role] !== true)
    .filter(role => clone.roles[role] !== null)
    .filter(role => typeof clone.roles[role] === 'object')
    .filter(role => !Array.isArray(clone.roles[role]))
    .filter(role => user_roles.includes(role) || notIncludesNegatedRole(role, user_roles))
    .forEach(role => {
      merge(clone, clone.roles[role])
    })

  return clone
}

/**
@function notIncludesNegatedRole

@description
The utility method checks whether a negated role [prefixed with an exclamation mark !] is not included in the array of user roles.

@param {String} role A role name
@param {Array<string>} user_roles Array of roles assigned to the user
@returns {Boolean} True if the negated role is not included in the user_roles array.
*/
function notIncludesNegatedRole(role, user_roles) {

  // A negated role is prefixes with an exclamation mark.
  return role.match(/(?<=^!)(.*)/g)?.[0] ?
    !user_roles.includes(role.match(/(?<=^!)(.*)/g)?.[0]) :
    false
}