Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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  }