<?php
class PermissionUtil
{
/**
* Generate all possible variants to match against, eg. $permissionid = a.b.c then we get:
* [ *, a.*, a.b.*, a.b.c ]
* In case $permissionid ends with an asterisk, also set $wildcard and $wclen, e.g.
* $permissionid = a.b.* --> $wildcard = a.b. and $wclen = 4
*
* @param $permission string|string[] permission to mangle
* @param string[] $compare all the generated variants
* @param string|false $wildcard if $permission is a wildcard string this returns the matching variant
* @param int|false $wclen if $permission is a wildcard string, this is the length of the matching variant
*/
private static function makeComparisonVariants($permission, ?array &$compare, &$wildcard, &$wclen): void
{
if (!is_array($permission)) {
$permission = explode('.', $permission);
}
$partCount = count($permission);
$compare = [];
for ($i = 0; $i < $partCount; ++$i) {
$compare[] = $permission[0];
}
for ($i = 1; $i < $partCount; ++$i) {
$compare[$i - 1] .= '.*';
for ($j = $i; $j < $partCount; ++$j) {
$compare[$j] .= '.' . $permission[$i];
}
}
$compare[] = '*';
if ($permission[$partCount - 1] === '*') {
$wildcard = substr($compare[$partCount - 1], 0, -1);
$wclen = strlen($wildcard);
} else {
$wclen = $wildcard = false;
}
}
private static $permissionCacheLoc = [];
private static $permissionCacheAny = [];
/**
* Check if the user has the given permission (for the given location).
*
* @param int $userid userid to check
* @param string $permissionid permissionid to check
* @param int|null $locationid locationid to check or null if the location should be disregarded
* @return bool true if user has permission, false if not
*/
public static function userHasPermission(int $userid, string $permissionid, ?int $locationid): bool
{
$permissionid = strtolower($permissionid);
self::validatePermission($permissionid);
$parts = explode('.', $permissionid);
// Special case: To prevent lockout, userid === 1 always has permissionmanager.*
if ($parts[0] === 'permissionmanager' && User::getId() === 1)
return true;
// Limit query to first part of permissionid, which is always the module id
$prefix = $parts[0] . '.%';
$key = $userid . '_' . $permissionid;
$cacheAll = false;
if ($locationid === null) {
// Check if permission exists for any location (i.e. permission not location dependent)
$cache =& self::$permissionCacheAny;
if (array_key_exists($key, $cache))
return $cache[$key];
$res = Database::simpleQuery("SELECT permissionid FROM role_x_permission
INNER JOIN role_x_user USING (roleid)
WHERE role_x_user.userid = :userid AND (permissionid LIKE :prefix OR permissionid LIKE '*')",
compact('userid', 'prefix'));
// Quick bailout - no results
if ($res->rowCount() === 0) {
$cache[$key] = false;
return false;
}
} else {
// Check if permission exists for specific location
if ($locationid === 0) {
$locations = [0];
} else {
$locations = Location::getLocationRootChain($locationid);
$locations[] = 0;
}
$cache =& self::$permissionCacheLoc;
if (array_key_exists($key, $cache)) {
if (array_key_exists($locationid, $cache[$key]))
return $cache[$key][$locationid]; // Exact match - return immediately
foreach ($locations as $lid) {
if (array_key_exists($lid, $cache[$key]) && $cache[$key][$lid]) {
$cache[$key][$locationid] = true;
return true; // We have a parent location that allows access - take it
}
}
$cacheAll = true;
}
$params = compact('userid', 'prefix');
if ($cacheAll) {
$extra = '';
} else {
$extra = 'AND (locationid IN (:locations) OR locationid IS NULL)';
$params['locations'] = $locations;
}
$res = Database::simpleQuery("SELECT permissionid, locationid FROM role_x_permission
INNER JOIN role_x_user USING (roleid)
INNER JOIN role_x_location USING (roleid)
WHERE role_x_user.userid = :userid AND (permissionid LIKE :prefix OR permissionid LIKE '*')
$extra", $params);
// Quick bailout - no results
if ($res->rowCount() === 0 && !$cacheAll) {
foreach ($locations as $lid) {
$cache[$key][$lid] = false;
}
return false;
}
}
// Compare to database result
if ($cacheAll) {
$allLocs = Location::getLocationsAssoc();
} else {
$allLocs = [];
}
self::makeComparisonVariants($parts, $compare, $wildcard, $wclen);
$retval = false;
foreach ($res as $row) {
if (in_array($row['permissionid'], $compare, true)
|| ($wildcard !== false && strncmp($row['permissionid'], $wildcard, $wclen) === 0)) {
if (!$cacheAll || ($row['locationid'] == $locationid) || $row['locationid'] === null) {
$retval = true;
if (!$cacheAll)
break;
}
$cache[$key][(int)$row['locationid']] = true;
$list = ($row['locationid'] === null) ? array_keys($allLocs) : $allLocs[(int)$row['locationid']]['children'];
foreach ($list as $lid) {
$cache[$key][$lid] = true;
}
if ($row['locationid'] === null)
break;
}
}
if ($locationid === null) {
$cache[$key] = $retval;
} else {
$cache[$key][$locationid] = $retval;
}
return $retval;
}
/**
* Get all locations where the user has the given permission.
*
* @param int $userid userid to check
* @param string $permissionid permissionid to check
* @return array array of locationids where the user has the given permission
*/
public static function getAllowedLocations(int $userid, string $permissionid): array
{
$permissionid = strtolower($permissionid);
self::validatePermission($permissionid);
$parts = explode('.', $permissionid);
// Special case: To prevent lockout, userid === 1 always has permissionmanager.*
if ($parts[0] === 'permissionmanager' && User::getId() === 1) {
$allowedLocations = [true];
} else {
// Limit query to first part of permissionid, which is always the module id
$prefix = $parts[0] . '.%';
$res = Database::simpleQuery("SELECT permissionid, locationid FROM role_x_permission
INNER JOIN role_x_user USING (roleid)
INNER JOIN role_x_location USING (roleid)
WHERE role_x_user.userid = :userid AND (permissionid LIKE :prefix OR permissionid LIKE '*')",
compact('userid', 'prefix'));
// Gather locationid from relevant rows
self::makeComparisonVariants($parts, $compare, $wildcard, $wclen);
$allowedLocations = array();
foreach ($res as $row) {
if (in_array($row['permissionid'], $compare, true)
|| ($wildcard !== false && strncmp($row['permissionid'], $wildcard, $wclen) === 0)) {
$allowedLocations[(int)$row['locationid']] = true;
}
}
}
$locations = Location::getTree();
if (isset($allowedLocations[0])) {
// Trivial case - have permission for all locations, so populate list with all valid locationds
$allowedLocations = Location::extractIds($locations);
$allowedLocations[] = 0; // .. plus 0 to show that we have global perms
} else {
// We have a specific list of locationds - add any sublocations to list
$allowedLocations = self::getSublocations($locations, $allowedLocations);
}
return $allowedLocations;
}
/**
* Extend an array of locations by adding all sublocations.
*
* @param array $tree tree of all locations (structured like Location::getTree())
* @param int[] $allowedLocations the array of locationids to extend
* @return array extended array of locationids
*/
public static function getSublocations(array $tree, array $allowedLocations): array
{
$result = $allowedLocations;
foreach ($tree as $location) {
if (array_key_exists("children", $location)) {
if (isset($allowedLocations[$location["locationid"]])) {
$result += array_flip(Location::extractIds($location["children"]));
} else {
$result += array_flip(self::getSublocations($location["children"], $allowedLocations));
}
}
}
return array_keys($result);
}
/**
* If in debug mode, validate that the checked permission is actually defined
* in the according permissions.json and complain if that's not the case.
* This is supposed to catch misspelled permission checks.
*
* @param string $permissionId permission to check
*/
private static function validatePermission(string $permissionId): void
{
if (!CONFIG_DEBUG || $permissionId === '*')
return;
$split = explode('.', $permissionId, 2);
if (count($split) !== 2) {
trigger_error('[skip:3]Cannot check malformed permission "' . $permissionId . '"', E_USER_WARNING);
return;
}
if ($split[1] === '*')
return;
$data = json_decode(file_get_contents('modules/' . $split[0] . '/permissions/permissions.json'), true);
if (substr($split[1], -2) === '.*') {
$len = strlen($split[1]) - 1;
foreach ($data as $perm => $v) {
if (strncmp($split[1], $perm, $len) === 0)
return;
}
trigger_error('[skip:3]Permission "' . $permissionId . '" does not match anything defined for module', E_USER_WARNING);
} elseif (!is_array($data) || !array_key_exists($split[1], $data)) {
trigger_error('[skip:3]Permission "' . $permissionId . '" not defined for module', E_USER_WARNING);
}
}
/**
* Get all permissions of all active modules that have permissions in their permissions/permissions.json file.
*
* @return array permission tree as a multidimensional array
*/
public static function getPermissions(): array
{
$permissions = array();
foreach (glob("modules/*/permissions/permissions.json", GLOB_NOSORT) as $file) {
$data = json_decode(file_get_contents($file), true);
if (!is_array($data))
continue;
preg_match('#^modules/([^/]+)/#', $file, $out);
$moduleId = $out[1];
if (Module::get($moduleId) === false)
continue;
foreach ($data as $perm => $permissionFlags) {
$description = Dictionary::translateFileModule($moduleId, "permissions", $perm);
self::putInPermissionTree($moduleId . "." . $perm, $permissionFlags['location-aware'] ?? false,
$description, $permissions);
}
}
ksort($permissions);
global $MENU_CAT_OVERRIDE;
$sortingOrder = $MENU_CAT_OVERRIDE;
foreach ($permissions as $module => $v) {
$sortingOrder[Module::get($module)->getCategory()][] = $module;
}
$sortingOrder = array_values($sortingOrder);
$permissions = array_replace(array_flip(call_user_func_array('array_merge', $sortingOrder)), $permissions);
foreach ($permissions as $module => $v) {
if (is_int($v)) {
unset($permissions[$module]);
}
}
return $permissions;
}
/**
* Get all existing roles.
*
* @param ?int $userid Which user to consider, false = none (list all)
* @return array list of roles
*/
public static function getRoles(?int $userid = null): array
{
if ($userid === false) {
return Database::queryAll('SELECT roleid, rolename FROM role ORDER BY rolename ASC');
}
$ret = Database::queryAll('SELECT r.roleid, r.rolename, u.userid AS hasRole FROM role r
LEFT JOIN role_x_user u ON (r.roleid = u.roleid AND u.userid = :userid)
GROUP BY r.roleid, r.rolename
ORDER BY rolename ASC', ['userid' => $userid]);
foreach ($ret as &$role) {
settype($role['hasRole'], 'bool');
}
return $ret;
}
/**
* Place a permission into the given permission tree.
*
* @param string $permission the permission to place in the tree
* @param bool $locationAware whether this permissions can be restricted to specific locations only
* @param string $description the description of the permission
* @param array $tree the permission tree to modify
*/
private static function putInPermissionTree(string $permission, bool $locationAware, string $description, array &$tree): void
{
$subPermissions = explode('.', $permission);
foreach ($subPermissions as $subPermission) {
if ($subPermission) {
if (!array_key_exists($subPermission, $tree)) {
$tree[$subPermission] = array();
}
$tree =& $tree[$subPermission];
}
}
$tree = array('description' => $description, 'location-aware' => $locationAware, 'isLeaf' => true);
}
}