// Copyright Abridged, Inc. 2022,2024. All Rights Reserved. // Node module: @collabland/common // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT import toposort from 'toposort'; import {loggers} from './debug-factory.js'; const {debug} = loggers('collabland:common:role-composition:info'); /** * Add/remove roles based on other roles */ export type RoleComposition = { /** * Add or remove a role * - add: Add the role if the condition is satisfied, otherwise leave the role as is * - remove: Remove the role if the condition is satisfied, otherwise leave the role as is * - addOrRemove: Add the role if the condition is satisfied, otherwise remove the role * - removeOrAdd: Remove the role if the condition is satisfied, otherwise add the role */ action: 'add' | 'remove' | 'addOrRemove' | 'removeOrAdd'; /** * Role id to be added/removed */ roleId: string; /** * AND/OR condition for the */ operator: 'and' | 'or'; /** * An object of roleId => state mapping as the predicate * * @example * ```ts * { * 'role1': true, * 'role2': false * } * ``` */ condition: Record; }; function describe(rule: RoleComposition) { const clauses = Object.entries(rule.condition) .map(([r, s]) => `${r} = ${s}`) .join(` ${rule.operator} `); return `${rule.action} role ${rule.roleId} if ${clauses}`; } /** * Sort rules based on the dependencies * @param rules - A list of rules * @returns */ export function sortRoleCompositions(rules: RoleComposition[]) { const edges = rules .map(r => Object.keys(r.condition).map(s => [s, r.roleId] as [string, string]), ) .flat(); const roleIds = toposort(edges); debug('Sorted role ids: %O', roleIds); const pendingRules = new Set(rules); const visited = new Set(); const queue: RoleComposition[] = []; for (const r of roleIds) { visited.add(r); pendingRules.forEach((rule, i) => { const fulfilled = Object.keys(rule.condition).every(s => visited.has(s)); if (fulfilled) { pendingRules.delete(rule); queue.push(rule); } }); } if (debug.enabled) { debug('Sorted rules: %O', queue.map(describe)); } return queue; } /** * Apply role composition rules with the initial list of roles * @param rules - Rules to apply * @param roles - Initial list of roles * @param updatedRoles - Roles are being updated in this process * @returns */ export function applyRoleCompositions( rules: RoleComposition[], roles: Record, updatedRoles = new Set(), ): Record { if (rules.length === 0) { return roles; } debug('Initial roles: %O', roles); roles = {...roles}; const queue = sortRoleCompositions(rules); for (const rule of queue) { let ruleDescription = ''; if (debug.enabled) { ruleDescription = describe(rule); } debug('Processing rule: %s', ruleDescription); let satisfied = false; if (rule.operator === 'or') { satisfied = Object.entries(rule.condition).some( ([r, s]) => roles[r] === s, ); } else { satisfied = Object.entries(rule.condition).every( ([r, s]) => roles[r] === s, ); } if (debug.enabled) { debug( 'Rule %s satisfied by %O: %s', satisfied ? 'is' : 'is NOT', Object.keys(rule.condition) .map(k => `${k} = ${roles[k]}`) .join(` ${rule.operator} `), ruleDescription, ); } if (satisfied) { // Unconditional enforcements if (rule.action === 'remove' || rule.action === 'removeOrAdd') { debug('Role %s is removed', rule.roleId); roles[rule.roleId] = false; updatedRoles.add(rule.roleId); } else if (rule.action === 'add' || rule.action === 'addOrRemove') { debug('Role %s is added', rule.roleId); roles[rule.roleId] = true; updatedRoles.add(rule.roleId); } } else { // Force reverse action (only if the role is not being updated in this run) if (rule.action === 'removeOrAdd') { debug('Role %s is added for removeOrAdd', rule.roleId); if (!updatedRoles.has(rule.roleId)) { roles[rule.roleId] = true; updatedRoles.add(rule.roleId); } else { debug('Skipping role addition %s', rule.roleId); } } else if (rule.action === 'addOrRemove') { debug('Role %s is removed for addOrRemove', rule.roleId); if (!updatedRoles.has(rule.roleId)) { roles[rule.roleId] = false; updatedRoles.add(rule.roleId); } else { debug('Skipping role removal %s', rule.roleId); } } } } debug('Composed roles: %O', roles); return roles; }