You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

432 lines
18 KiB

<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Helper functions to implement the complex get_user_capability_course function.
*
* @package core
* @copyright 2017 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core\access;
defined('MOODLE_INTERNAL') || die();
/**
* Helper functions to implement the complex get_user_capability_course function.
*
* @package core
* @copyright 2017 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class get_user_capability_course_helper {
/**
* Based on the given user's access data (roles) and system role definitions, works out
* an array of capability values at each relevant context for the given user and capability.
*
* This is organised by the effective context path (the one at which the capability takes
* effect) and then by role id. Note, however, that the resulting array only has
* the information that will be needed later. If there are Prohibits present in some
* roles, then they cannot be overridden by other roles or role overrides in lower contexts,
* therefore, such information, if any, is absent from the results.
*
* @param int $userid User id
* @param string $capability Capability e.g. 'moodle/course:view'
* @return array Array of capability constants, indexed by context path and role id
*/
protected static function get_capability_info_at_each_context($userid, $capability) {
// Get access data for user.
$accessdata = get_user_accessdata($userid);
// Get list of roles for user (any location) and information about these roles.
$roleids = [];
foreach ($accessdata['ra'] as $path => $roles) {
foreach ($roles as $roleid) {
$roleids[$roleid] = true;
}
}
$rdefs = get_role_definitions(array_keys($roleids));
// A prohibit in any relevant role prevents the capability
// in that context and all subcontexts. We need to track that.
// Here, the array keys are the paths where there is a prohibit the values are the role id.
$prohibitpaths = [];
// Get data for required capability at each context path where the user has a role that can
// affect it.
$pathroleperms = [];
foreach ($accessdata['ra'] as $rapath => $roles) {
foreach ($roles as $roleid) {
// Get role definition for that role.
foreach ($rdefs[$roleid] as $rdefpath => $caps) {
// Ignore if this override/definition doesn't refer to the relevant cap.
if (!array_key_exists($capability, $caps)) {
continue;
}
// Check a role definition or override above ra.
if (self::path_is_above($rdefpath, $rapath)) {
// Note that $rdefs is sorted by path, so if a more specific override
// exists, it will be processed later and override this one.
$effectivepath = $rapath;
} else if (self::path_is_above($rapath, $rdefpath)) {
$effectivepath = $rdefpath;
} else {
// Not inside an area where the user has the role, so ignore.
continue;
}
// Check for already seen prohibits in higher context. Overrides can't change that.
if (self::any_path_is_above($prohibitpaths, $effectivepath)) {
continue;
}
// This is a releavant role assignment / permission combination. Save it.
if (!array_key_exists($effectivepath, $pathroleperms)) {
$pathroleperms[$effectivepath] = [];
}
$pathroleperms[$effectivepath][$roleid] = $caps[$capability];
// Update $prohibitpaths if necessary.
if ($caps[$capability] == CAP_PROHIBIT) {
// First remove any lower-context prohibits that might have come from other roles.
foreach ($prohibitpaths as $otherprohibitpath => $notused) {
if (self::path_is_above($effectivepath, $otherprohibitpath)) {
unset($prohibitpaths[$otherprohibitpath]);
}
}
$prohibitpaths[$effectivepath] = $roleid;
}
}
}
}
// Finally, if a later role had a higher-level prohibit that an earlier role,
// there may be more bits we can prune - but don't prune the prohibits!
foreach ($pathroleperms as $effectivepath => $roleperms) {
if ($roleid = self::any_path_is_above($prohibitpaths, $effectivepath)) {
unset($pathroleperms[$effectivepath]);
$pathroleperms[$effectivepath][$roleid] = CAP_PROHIBIT;
}
}
return $pathroleperms;
}
/**
* Test if a context path $otherpath is the same as, or underneath, $parentpath.
*
* @param string $parentpath the path of the parent context.
* @param string $otherpath the path of another context.
* @return bool true if $otherpath is underneath (or equal to) $parentpath.
*/
protected static function path_is_above($parentpath, $otherpath) {
return preg_match('~^' . $parentpath . '($|/)~', $otherpath);
}
/**
* Test if a context path $otherpath is the same as, or underneath, any of $prohibitpaths.
*
* @param array $prohibitpaths array keys are context paths.
* @param string $otherpath the path of another context.
* @return int releavant $roleid if $otherpath is underneath (or equal to)
* any of the $prohibitpaths, 0 otherwise (so, can be used as a bool).
*/
protected static function any_path_is_above($prohibitpaths, $otherpath) {
foreach ($prohibitpaths as $prohibitpath => $roleid) {
if (self::path_is_above($prohibitpath, $otherpath)) {
return $roleid;
}
}
return 0;
}
/**
* Calculates a permission tree based on an array of information about role permissions.
*
* The input parameter must be in the format returned by get_capability_info_at_each_context.
*
* The output is the root of a tree of stdClass objects with the fields 'path' (a context path),
* 'allow' (true or false), and 'children' (an array of similar objects).
*
* @param array $pathroleperms Array of permissions
* @return \stdClass Root object of permission tree
*/
protected static function calculate_permission_tree(array $pathroleperms) {
// Considering each discovered context path as an inflection point, evaluate the user's
// permission (based on all roles) at each point.
$pathallows = [];
$mindepth = 1000;
$maxdepth = 0;
foreach ($pathroleperms as $path => $roles) {
$evaluatedroleperms = [];
// Walk up the tree starting from this path.
$innerpath = $path;
while ($innerpath !== '') {
$roles = $pathroleperms[$innerpath];
// Evaluate roles at this path level.
foreach ($roles as $roleid => $perm) {
if (!array_key_exists($roleid, $evaluatedroleperms)) {
$evaluatedroleperms[$roleid] = $perm;
} else {
// The existing one is at a more specific level so it takes precedence
// UNLESS this is a prohibit.
if ($perm == CAP_PROHIBIT) {
$evaluatedroleperms[$roleid] = $perm;
}
}
}
// Go up to next path level (if any).
do {
$innerpath = substr($innerpath, 0, strrpos($innerpath, '/'));
if ($innerpath === '') {
// No higher level data.
break;
}
} while (!array_key_exists($innerpath, $pathroleperms));
}
// If we have an allow from any role, and no prohibits, then user can access this path,
// else not.
$allow = false;
foreach ($evaluatedroleperms as $perm) {
if ($perm == CAP_ALLOW) {
$allow = true;
} else if ($perm == CAP_PROHIBIT) {
$allow = false;
break;
}
}
// Store the result based on path and depth so that we can process in depth order in
// the next step.
$depth = strlen(preg_replace('~[^/]~', '', $path));
$mindepth = min($depth, $mindepth);
$maxdepth = max($depth, $maxdepth);
$pathallows[$depth][$path] = $allow;
}
// Organise into a tree structure, processing in depth order so that we have ancestors
// set up before we encounter their children.
$root = (object)['allow' => false, 'path' => null, 'children' => []];
$nodesbypath = [];
for ($depth = $mindepth; $depth <= $maxdepth; $depth++) {
// Skip any missing depth levels.
if (!array_key_exists($depth, $pathallows)) {
continue;
}
foreach ($pathallows[$depth] as $path => $allow) {
// Value for new tree node.
$leaf = (object)['allow' => $allow, 'path' => $path, 'children' => []];
// Try to find a place to join it on if there is one.
$ancestorpath = $path;
$found = false;
while ($ancestorpath) {
$ancestorpath = substr($ancestorpath, 0, strrpos($ancestorpath, '/'));
if (array_key_exists($ancestorpath, $nodesbypath)) {
$found = true;
break;
}
}
if ($found) {
$nodesbypath[$ancestorpath]->children[] = $leaf;
} else {
$root->children[] = $leaf;
}
$nodesbypath[$path] = $leaf;
}
}
return $root;
}
/**
* Given a permission tree (in calculate_permission_tree format), removes any subtrees that
* are negative from the root. For example, if a top-level node of the permission tree has
* 'false' permission then it is meaningless because the default permission is already false;
* this function will remove it. However, if there is a child within that node that is positive,
* then that will need to be kept.
*
* @param \stdClass $root Root object
* @return \stdClass Filtered tree root
*/
protected static function remove_negative_subtrees($root) {
// If a node 'starts' negative, we don't need it (as negative is the default) - extract only
// subtrees that start with a positive value.
$positiveroot = (object)['allow' => false, 'path' => null, 'children' => []];
$consider = [$root];
while ($consider) {
$first = array_shift($consider);
foreach ($first->children as $node) {
if ($node->allow) {
// Add directly to new root.
$positiveroot->children[] = $node;
} else {
// Consider its children for adding to root (if there are any positive ones).
$consider[] = $node;
}
}
}
return $positiveroot;
}
/**
* Removes duplicate nodes of a tree - where a child node has the same permission as its
* parent.
*
* @param \stdClass $parent Tree root node
*/
protected static function remove_duplicate_nodes($parent) {
$length = count($parent->children);
$index = 0;
while ($index < $length) {
$child = $parent->children[$index];
if ($child->allow === $parent->allow) {
// Remove child node, but add its children to this node instead.
array_splice($parent->children, $index, 1);
$length--;
$index--;
foreach ($child->children as $grandchild) {
$parent->children[] = $grandchild;
$length++;
}
} else {
// Keep child node, but recurse to remove its unnecessary children.
self::remove_duplicate_nodes($child);
}
$index++;
}
}
/**
* Gets a permission tree for the given user and capability, representing the value of that
* capability at different contexts across the system. The tree will be simplified as far as
* possible.
*
* The output is the root of a tree of stdClass objects with the fields 'path' (a context path),
* 'allow' (true or false), and 'children' (an array of similar objects).
*
* @param int $userid User id
* @param string $capability Capability e.g. 'moodle/course:view'
* @return \stdClass Root node of tree
*/
protected static function get_tree($userid, $capability) {
// Extract raw capability data for this user and capability.
$pathroleperms = self::get_capability_info_at_each_context($userid, $capability);
// Convert the raw data into a permission tree based on context.
$root = self::calculate_permission_tree($pathroleperms);
unset($pathroleperms);
// Simplify the permission tree by removing unnecessary nodes.
$root = self::remove_negative_subtrees($root);
self::remove_duplicate_nodes($root);
// Return the tree.
return $root;
}
/**
* Creates SQL suitable for restricting by contexts listed in the given permission tree.
*
* This function relies on the permission tree being in the format created by get_tree.
* Specifically, all the children of the root element must be set to 'allow' permission,
* children of those children must be 'not allow', children of those grandchildren 'allow', etc.
*
* @param \stdClass $parent Root node of permission tree
* @return array Two-element array of SQL (containing ? placeholders) and then a params array
*/
protected static function create_sql($parent) {
global $DB;
$sql = '';
$params = [];
if ($parent->path !== null) {
// Except for the root element, create the condition that it applies to the context of
// this element (or anything within it).
$sql = ' (x.path = ? OR ' . $DB->sql_like('x.path', '?') .')';
$params[] = $parent->path;
$params[] = $parent->path . '/%';
if ($parent->children) {
// When there are children, these are assumed to have the opposite sign i.e. if we
// are allowing the parent, we are not allowing the children, and vice versa. So
// the 'OR' clause for children will be inside this 'AND NOT'.
$sql .= ' AND NOT (';
}
} else if (count($parent->children) > 1) {
// Place brackets in the query when it is going to be an OR of multiple conditions.
$sql .= ' (';
}
if ($parent->children) {
$first = true;
foreach ($parent->children as $child) {
if ($first) {
$first = false;
} else {
$sql .= ' OR';
}
// Recuse to get the child requirements - this will be the check that the context
// is within the child, plus possibly and 'AND NOT' for any different contexts
// within the child.
list ($childsql, $childparams) = self::create_sql($child);
$sql .= $childsql;
$params = array_merge($params, $childparams);
}
// Close brackets if opened above.
if ($parent->path !== null || count($parent->children) > 1) {
$sql .= ')';
}
}
return [$sql, $params];
}
/**
* Gets SQL to restrict a query to contexts in which the user has a capability.
*
* This returns an array with two elements (SQL containing ? placeholders, and a params array).
* The SQL is intended to be used as part of a WHERE clause. It relies on the prefix 'x' being
* used for the Moodle context table.
*
* If the user does not have the permission anywhere at all (so that there is no point doing
* the query) then the two returned values will both be false.
*
* @param int $userid User id
* @param string $capability Capability e.g. 'moodle/course:view'
* @return array Two-element array of SQL (containing ? placeholders) and then a params array
*/
public static function get_sql($userid, $capability) {
// Get a tree of capability permission at various contexts for current user.
$root = self::get_tree($userid, $capability);
// The root node always has permission false. If there are no child nodes then the user
// cannot access anything.
if (!$root->children) {
return [false, false];
}
// Get SQL to limit contexts based on the permission tree.
return self::create_sql($root);
}
}