See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401]
1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 /** 18 * Helper functions to implement the complex get_user_capability_course function. 19 * 20 * @package core 21 * @copyright 2017 The Open University 22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 */ 24 25 namespace core\access; 26 27 defined('MOODLE_INTERNAL') || die(); 28 29 /** 30 * Helper functions to implement the complex get_user_capability_course function. 31 * 32 * @package core 33 * @copyright 2017 The Open University 34 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 35 */ 36 class get_user_capability_course_helper { 37 /** 38 * Based on the given user's access data (roles) and system role definitions, works out 39 * an array of capability values at each relevant context for the given user and capability. 40 * 41 * This is organised by the effective context path (the one at which the capability takes 42 * effect) and then by role id. Note, however, that the resulting array only has 43 * the information that will be needed later. If there are Prohibits present in some 44 * roles, then they cannot be overridden by other roles or role overrides in lower contexts, 45 * therefore, such information, if any, is absent from the results. 46 * 47 * @param int $userid User id 48 * @param string $capability Capability e.g. 'moodle/course:view' 49 * @return array Array of capability constants, indexed by context path and role id 50 */ 51 protected static function get_capability_info_at_each_context($userid, $capability) { 52 // Get access data for user. 53 $accessdata = get_user_accessdata($userid); 54 55 // Get list of roles for user (any location) and information about these roles. 56 $roleids = []; 57 foreach ($accessdata['ra'] as $path => $roles) { 58 foreach ($roles as $roleid) { 59 $roleids[$roleid] = true; 60 } 61 } 62 $rdefs = get_role_definitions(array_keys($roleids)); 63 64 // A prohibit in any relevant role prevents the capability 65 // in that context and all subcontexts. We need to track that. 66 // Here, the array keys are the paths where there is a prohibit the values are the role id. 67 $prohibitpaths = []; 68 69 // Get data for required capability at each context path where the user has a role that can 70 // affect it. 71 $pathroleperms = []; 72 foreach ($accessdata['ra'] as $rapath => $roles) { 73 74 foreach ($roles as $roleid) { 75 // Get role definition for that role. 76 foreach ($rdefs[$roleid] as $rdefpath => $caps) { 77 // Ignore if this override/definition doesn't refer to the relevant cap. 78 if (!array_key_exists($capability, $caps)) { 79 continue; 80 } 81 82 // Check a role definition or override above ra. 83 if (self::path_is_above($rdefpath, $rapath)) { 84 // Note that $rdefs is sorted by path, so if a more specific override 85 // exists, it will be processed later and override this one. 86 $effectivepath = $rapath; 87 } else if (self::path_is_above($rapath, $rdefpath)) { 88 $effectivepath = $rdefpath; 89 } else { 90 // Not inside an area where the user has the role, so ignore. 91 continue; 92 } 93 94 // Check for already seen prohibits in higher context. Overrides can't change that. 95 if (self::any_path_is_above($prohibitpaths, $effectivepath)) { 96 continue; 97 } 98 99 // This is a releavant role assignment / permission combination. Save it. 100 if (!array_key_exists($effectivepath, $pathroleperms)) { 101 $pathroleperms[$effectivepath] = []; 102 } 103 $pathroleperms[$effectivepath][$roleid] = $caps[$capability]; 104 105 // Update $prohibitpaths if necessary. 106 if ($caps[$capability] == CAP_PROHIBIT) { 107 // First remove any lower-context prohibits that might have come from other roles. 108 foreach ($prohibitpaths as $otherprohibitpath => $notused) { 109 if (self::path_is_above($effectivepath, $otherprohibitpath)) { 110 unset($prohibitpaths[$otherprohibitpath]); 111 } 112 } 113 $prohibitpaths[$effectivepath] = $roleid; 114 } 115 } 116 } 117 } 118 119 // Finally, if a later role had a higher-level prohibit that an earlier role, 120 // there may be more bits we can prune - but don't prune the prohibits! 121 foreach ($pathroleperms as $effectivepath => $roleperms) { 122 if ($roleid = self::any_path_is_above($prohibitpaths, $effectivepath)) { 123 unset($pathroleperms[$effectivepath]); 124 $pathroleperms[$effectivepath][$roleid] = CAP_PROHIBIT; 125 } 126 } 127 128 return $pathroleperms; 129 } 130 131 /** 132 * Test if a context path $otherpath is the same as, or underneath, $parentpath. 133 * 134 * @param string $parentpath the path of the parent context. 135 * @param string $otherpath the path of another context. 136 * @return bool true if $otherpath is underneath (or equal to) $parentpath. 137 */ 138 protected static function path_is_above($parentpath, $otherpath) { 139 return preg_match('~^' . $parentpath . '($|/)~', $otherpath); 140 } 141 142 /** 143 * Test if a context path $otherpath is the same as, or underneath, any of $prohibitpaths. 144 * 145 * @param array $prohibitpaths array keys are context paths. 146 * @param string $otherpath the path of another context. 147 * @return int releavant $roleid if $otherpath is underneath (or equal to) 148 * any of the $prohibitpaths, 0 otherwise (so, can be used as a bool). 149 */ 150 protected static function any_path_is_above($prohibitpaths, $otherpath) { 151 foreach ($prohibitpaths as $prohibitpath => $roleid) { 152 if (self::path_is_above($prohibitpath, $otherpath)) { 153 return $roleid; 154 } 155 } 156 return 0; 157 } 158 159 /** 160 * Calculates a permission tree based on an array of information about role permissions. 161 * 162 * The input parameter must be in the format returned by get_capability_info_at_each_context. 163 * 164 * The output is the root of a tree of stdClass objects with the fields 'path' (a context path), 165 * 'allow' (true or false), and 'children' (an array of similar objects). 166 * 167 * @param array $pathroleperms Array of permissions 168 * @return \stdClass Root object of permission tree 169 */ 170 protected static function calculate_permission_tree(array $pathroleperms) { 171 // Considering each discovered context path as an inflection point, evaluate the user's 172 // permission (based on all roles) at each point. 173 $pathallows = []; 174 $mindepth = 1000; 175 $maxdepth = 0; 176 foreach ($pathroleperms as $path => $roles) { 177 $evaluatedroleperms = []; 178 179 // Walk up the tree starting from this path. 180 $innerpath = $path; 181 while ($innerpath !== '') { 182 $roles = $pathroleperms[$innerpath]; 183 184 // Evaluate roles at this path level. 185 foreach ($roles as $roleid => $perm) { 186 if (!array_key_exists($roleid, $evaluatedroleperms)) { 187 $evaluatedroleperms[$roleid] = $perm; 188 } else { 189 // The existing one is at a more specific level so it takes precedence 190 // UNLESS this is a prohibit. 191 if ($perm == CAP_PROHIBIT) { 192 $evaluatedroleperms[$roleid] = $perm; 193 } 194 } 195 } 196 197 // Go up to next path level (if any). 198 do { 199 $innerpath = substr($innerpath, 0, strrpos($innerpath, '/')); 200 if ($innerpath === '') { 201 // No higher level data. 202 break; 203 } 204 } while (!array_key_exists($innerpath, $pathroleperms)); 205 } 206 207 // If we have an allow from any role, and no prohibits, then user can access this path, 208 // else not. 209 $allow = false; 210 foreach ($evaluatedroleperms as $perm) { 211 if ($perm == CAP_ALLOW) { 212 $allow = true; 213 } else if ($perm == CAP_PROHIBIT) { 214 $allow = false; 215 break; 216 } 217 } 218 219 // Store the result based on path and depth so that we can process in depth order in 220 // the next step. 221 $depth = strlen(preg_replace('~[^/]~', '', $path)); 222 $mindepth = min($depth, $mindepth); 223 $maxdepth = max($depth, $maxdepth); 224 $pathallows[$depth][$path] = $allow; 225 } 226 227 // Organise into a tree structure, processing in depth order so that we have ancestors 228 // set up before we encounter their children. 229 $root = (object)['allow' => false, 'path' => null, 'children' => []]; 230 $nodesbypath = []; 231 for ($depth = $mindepth; $depth <= $maxdepth; $depth++) { 232 // Skip any missing depth levels. 233 if (!array_key_exists($depth, $pathallows)) { 234 continue; 235 } 236 foreach ($pathallows[$depth] as $path => $allow) { 237 // Value for new tree node. 238 $leaf = (object)['allow' => $allow, 'path' => $path, 'children' => []]; 239 240 // Try to find a place to join it on if there is one. 241 $ancestorpath = $path; 242 $found = false; 243 while ($ancestorpath) { 244 $ancestorpath = substr($ancestorpath, 0, strrpos($ancestorpath, '/')); 245 if (array_key_exists($ancestorpath, $nodesbypath)) { 246 $found = true; 247 break; 248 } 249 } 250 251 if ($found) { 252 $nodesbypath[$ancestorpath]->children[] = $leaf; 253 } else { 254 $root->children[] = $leaf; 255 } 256 $nodesbypath[$path] = $leaf; 257 } 258 } 259 260 return $root; 261 } 262 263 /** 264 * Given a permission tree (in calculate_permission_tree format), removes any subtrees that 265 * are negative from the root. For example, if a top-level node of the permission tree has 266 * 'false' permission then it is meaningless because the default permission is already false; 267 * this function will remove it. However, if there is a child within that node that is positive, 268 * then that will need to be kept. 269 * 270 * @param \stdClass $root Root object 271 * @return \stdClass Filtered tree root 272 */ 273 protected static function remove_negative_subtrees($root) { 274 // If a node 'starts' negative, we don't need it (as negative is the default) - extract only 275 // subtrees that start with a positive value. 276 $positiveroot = (object)['allow' => false, 'path' => null, 'children' => []]; 277 $consider = [$root]; 278 while ($consider) { 279 $first = array_shift($consider); 280 foreach ($first->children as $node) { 281 if ($node->allow) { 282 // Add directly to new root. 283 $positiveroot->children[] = $node; 284 } else { 285 // Consider its children for adding to root (if there are any positive ones). 286 $consider[] = $node; 287 } 288 } 289 } 290 return $positiveroot; 291 } 292 293 /** 294 * Removes duplicate nodes of a tree - where a child node has the same permission as its 295 * parent. 296 * 297 * @param \stdClass $parent Tree root node 298 */ 299 protected static function remove_duplicate_nodes($parent) { 300 $length = count($parent->children); 301 $index = 0; 302 while ($index < $length) { 303 $child = $parent->children[$index]; 304 if ($child->allow === $parent->allow) { 305 // Remove child node, but add its children to this node instead. 306 array_splice($parent->children, $index, 1); 307 $length--; 308 $index--; 309 foreach ($child->children as $grandchild) { 310 $parent->children[] = $grandchild; 311 $length++; 312 } 313 } else { 314 // Keep child node, but recurse to remove its unnecessary children. 315 self::remove_duplicate_nodes($child); 316 } 317 $index++; 318 } 319 } 320 321 /** 322 * Gets a permission tree for the given user and capability, representing the value of that 323 * capability at different contexts across the system. The tree will be simplified as far as 324 * possible. 325 * 326 * The output is the root of a tree of stdClass objects with the fields 'path' (a context path), 327 * 'allow' (true or false), and 'children' (an array of similar objects). 328 * 329 * @param int $userid User id 330 * @param string $capability Capability e.g. 'moodle/course:view' 331 * @return \stdClass Root node of tree 332 */ 333 protected static function get_tree($userid, $capability) { 334 // Extract raw capability data for this user and capability. 335 $pathroleperms = self::get_capability_info_at_each_context($userid, $capability); 336 337 // Convert the raw data into a permission tree based on context. 338 $root = self::calculate_permission_tree($pathroleperms); 339 unset($pathroleperms); 340 341 // Simplify the permission tree by removing unnecessary nodes. 342 $root = self::remove_negative_subtrees($root); 343 self::remove_duplicate_nodes($root); 344 345 // Return the tree. 346 return $root; 347 } 348 349 /** 350 * Creates SQL suitable for restricting by contexts listed in the given permission tree. 351 * 352 * This function relies on the permission tree being in the format created by get_tree. 353 * Specifically, all the children of the root element must be set to 'allow' permission, 354 * children of those children must be 'not allow', children of those grandchildren 'allow', etc. 355 * 356 * @param \stdClass $parent Root node of permission tree 357 * @return array Two-element array of SQL (containing ? placeholders) and then a params array 358 */ 359 protected static function create_sql($parent) { 360 global $DB; 361 362 $sql = ''; 363 $params = []; 364 if ($parent->path !== null) { 365 // Except for the root element, create the condition that it applies to the context of 366 // this element (or anything within it). 367 $sql = ' (x.path = ? OR ' . $DB->sql_like('x.path', '?') .')'; 368 $params[] = $parent->path; 369 $params[] = $parent->path . '/%'; 370 if ($parent->children) { 371 // When there are children, these are assumed to have the opposite sign i.e. if we 372 // are allowing the parent, we are not allowing the children, and vice versa. So 373 // the 'OR' clause for children will be inside this 'AND NOT'. 374 $sql .= ' AND NOT ('; 375 } 376 } else if (count($parent->children) > 1) { 377 // Place brackets in the query when it is going to be an OR of multiple conditions. 378 $sql .= ' ('; 379 } 380 if ($parent->children) { 381 $first = true; 382 foreach ($parent->children as $child) { 383 if ($first) { 384 $first = false; 385 } else { 386 $sql .= ' OR'; 387 } 388 389 // Recuse to get the child requirements - this will be the check that the context 390 // is within the child, plus possibly and 'AND NOT' for any different contexts 391 // within the child. 392 list ($childsql, $childparams) = self::create_sql($child); 393 $sql .= $childsql; 394 $params = array_merge($params, $childparams); 395 } 396 // Close brackets if opened above. 397 if ($parent->path !== null || count($parent->children) > 1) { 398 $sql .= ')'; 399 } 400 } 401 return [$sql, $params]; 402 } 403 404 /** 405 * Gets SQL to restrict a query to contexts in which the user has a capability. 406 * 407 * This returns an array with two elements (SQL containing ? placeholders, and a params array). 408 * The SQL is intended to be used as part of a WHERE clause. It relies on the prefix 'x' being 409 * used for the Moodle context table. 410 * 411 * If the user does not have the permission anywhere at all (so that there is no point doing 412 * the query) then the two returned values will both be false. 413 * 414 * @param int $userid User id 415 * @param string $capability Capability e.g. 'moodle/course:view' 416 * @return array Two-element array of SQL (containing ? placeholders) and then a params array 417 */ 418 public static function get_sql($userid, $capability) { 419 // Get a tree of capability permission at various contexts for current user. 420 $root = self::get_tree($userid, $capability); 421 422 // The root node always has permission false. If there are no child nodes then the user 423 // cannot access anything. 424 if (!$root->children) { 425 return [false, false]; 426 } 427 428 // Get SQL to limit contexts based on the permission tree. 429 return self::create_sql($root); 430 431 } 432 433 /** 434 * Map fieldnames to get ready for the SQL query. 435 * 436 * @param string $fieldsexceptid A comma-separated list of the fields you require, not including id. 437 * Add ctxid, ctxpath, ctxdepth etc to return course context information for preloading. 438 * @return string Mapped field list for the SQL query. 439 */ 440 public static function map_fieldnames(string $fieldsexceptid = ''): string { 441 // Convert fields list and ordering. 442 $fieldlist = ''; 443 if ($fieldsexceptid) { 444 $fields = array_map('trim', explode(',', $fieldsexceptid)); 445 foreach ($fields as $field) { 446 // Context fields have a different alias. 447 if (strpos($field, 'ctx') === 0) { 448 switch($field) { 449 case 'ctxlevel' : 450 $realfield = 'contextlevel'; 451 break; 452 case 'ctxinstance' : 453 $realfield = 'instanceid'; 454 break; 455 default: 456 $realfield = substr($field, 3); 457 break; 458 } 459 $fieldlist .= ',x.' . $realfield . ' AS ' . $field; 460 } else { 461 $fieldlist .= ',c.'.$field; 462 } 463 } 464 } 465 return $fieldlist; 466 } 467 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body