mod_utils_roles.js

/**
## /utils/roles
Roles utility module exports methods to inspect roles in object, checking object access, and merging roles objects based on provided user roles.

@requires /utils/merge
@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)
*/

import merge from './merge.js';

/**
@function check
@description
Checks if an object should be accessible based on user roles

```js
// 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
```

The check is also passed if the obj does not have a roles property.

@param {Object} obj The object to check access for
@param {Array<string>} user_roles Array of roles assigned to the user
@property {roles} obj.roles Role configuration object
@returns {boolean} Returns true if check is passed, false otherwise.
*/
export function check(obj, user_roles) {
  // The object to check has no roles assigned.
  if (!obj.roles) return true;

  // The roles object maybe empty.
  if (!Object.keys(obj.roles).length) return true;

  if (!user_roles) return false;

  // user_roles must be an array or true
  if (!Array.isArray(user_roles)) return true;

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

  // Add last of dot notation role to rolesArr
  const rolesArr = Object.keys(obj.roles);

  // Pop last role from dot notation roles into rolesArr for backwards compatibility.
  Object.keys(obj.roles).forEach((role) =>
    rolesArr.push(role.split('.').pop()),
  );

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

  if (someNegatedRole) return false;

  // Check whether every role is negated.
  const everyNegatedRoles = rolesArr.every((role) => /^!/.exec(role));

  if (everyNegatedRoles) return true;

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

  if (somePositiveRole) return true;

  // 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
*/
export 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) => {
      // Get last role from a dot tree role string.
      const popRole = role.split('.').pop();

      // A negated role starts with an exclamation mark.
      const negatedRole = popRole.match(/(?<=^!)(.*)/g)?.[0];

      if (negatedRole) {
        // True if the user_roles NOT includes the negated role.
        return !user_roles.includes(negatedRole);
      }

      return user_roles.includes(popRole);
    })
    .forEach((role) => {
      merge(clone, clone.roles[role]);
    });

  return clone;
}

/**
@function setInObj

@description
The setInObj receives a set of roles and an object as params.

The method iterates through the object keys and will call itself for every object type property in the object param.

Any roles defined in the roles property of the object param will be added to the rolesSet param.

The method does not return anything but will modify the rolesSet param which is passed recursively.

Access roles defined as the role string property will also be added to the rolesSet.

@param {set} rolesSet Set of roles to be modified while the param is passed recursively.
@param {object} obj Object to evaluate for roles.
@property {object} [obj.roles] Roles in the object will be added to the rolesSet.
@property {string} [obj.role] Any [template] access role will be added to the rolesSet.
*/
export function setInObj(rolesSet, obj) {
  // Iterate through the object tree.
  Object.keys(obj).forEach((key) => {
    if (obj[key] && typeof obj[key] === 'object') {
      if (key === 'roles') {
        Object.keys(obj[key]).forEach((role) => {
          // Add role without negation ! to roles set.
          // The same role can not be added multiple times to the rolesSet.
          rolesSet.add(role.replace(/^!/, ''));
        });
      }

      // Call method recursively for object properties of the object param.
      setInObj(rolesSet, obj[key]);
    } else if (key === 'role' && typeof obj[key] === 'string') {
      // Also extract single role string properties
      rolesSet.add(obj[key].replace(/^!/, ''));
    }
  });
}

/**
@function combine
@description
Combines roles from a parent object into a child object.
Handles two use cases:
1. Template role assignment (when parent has localeRole, templateRole, or objRole properties)
2. Simple parent-child role combination (for nested locales)

@param {Object} child The child object (template or locale).
@param {Object} parent The parent object providing role context.
*/
export function combine(child, parent) {
  // Handle template-style role assignment
  // Template context has special properties: localeRole, templateRole, objRole
  if (
    child.role &&
    (parent.localeRole ||
      parent.templateRole ||
      parent.objRole ||
      child.objRole)
  ) {
    combineTemplateRoles(child, parent);
    return;
  }

  // Handle simple locale-style role combination
  combineLocaleRoles(child, parent);
}

/**
@function combineTemplateRoles
@description
Combines roles for templates. This replicates the old roleAssign behavior.

Templates may have an access role restriction. The `template.role` string property requires a user to have that role in order to access the template.

The role string will be added as boolean:true property to the `template.roles` object property if the property key is undefined.

`template.role = 'bar' -> template.roles = {'bar':true}`

A dot notation role key will be created if the obj has a role string property.

`obj.role = 'foo' && template.role = 'bar' -> template.roles = {'foo.bar':true}`

@param {Object} template The template object (child).
@param {Object} obj The parent object providing role context.
*/
function combineTemplateRoles(template, obj) {
  if (!template.role) return;

  template.roles ??= {};
  template.roles[template.role] ??= true;

  // Filter out undefined roles and duplicates from roles array.
  const roleArr = Array.from(
    new Set(
      [obj.localeRole, obj.role, obj.templateRole, template.role].filter(
        (role) => typeof role === 'string',
      ),
    ),
  );

  // Join roles array into the template.roles.
  if (roleArr.length) {
    template.roles[roleArr.join('.')] ??= true;
  }

  obj.roles ??= {};

  // Concatenate the template.role to each obj.roles{} key where the last role does not match the template.objRole.
  for (const role of Object.keys(obj.roles)) {
    const tailRole = role.split('.').pop();
    if (tailRole !== template.objRole) {
      continue;
    }
    template.roles[`${role}.${template.role}`] ??= true;
  }

  if (Array.isArray(template.templates)) {
    template.templateRole = template.role;
    for (const templatesTemplate of template.templates) {
      if (typeof templatesTemplate !== 'object') continue;
      templatesTemplate.objRole = template.role;
    }
  } else {
    delete obj.templateRole;
  }
}

/**
@function combineLocaleRoles
@description
Combines roles for nested locales. Creates parent.child role combinations.

@param {Object} child The child locale.
@param {Object} parent The parent locale.
*/
function combineLocaleRoles(child, parent) {
  if (!parent || !child) return;

  // Ensure roles objects exist
  child.roles ??= {};
  parent.roles ??= {};

  // Convert string role properties to roles object entries
  if (child.role && typeof child.role === 'string') {
    child.roles[child.role] ??= true;
  }

  if (parent.role && typeof parent.role === 'string') {
    parent.roles[parent.role] ??= true;
  }

  // Identify roles specific to the child (not present in parent)
  const specificChildRoles = Object.keys(child.roles).filter(
    (role) => !parent.roles[role],
  );

  // Create combinations Parent.Child
  Object.keys(parent.roles).forEach((parentRole) => {
    specificChildRoles.forEach((childRole) => {
      child.roles[`${parentRole}.${childRole}`] ??= true;
    });
  });
}