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 401 and 402] [Versions 401 and 403]

   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  namespace enrol_lti\local\ltiadvantage\task;
  18  
  19  use core\task\scheduled_task;
  20  use enrol_lti\helper;
  21  use enrol_lti\local\ltiadvantage\entity\application_registration;
  22  use enrol_lti\local\ltiadvantage\entity\nrps_info;
  23  use enrol_lti\local\ltiadvantage\entity\resource_link;
  24  use enrol_lti\local\ltiadvantage\entity\user;
  25  use enrol_lti\local\ltiadvantage\lib\http_client;
  26  use enrol_lti\local\ltiadvantage\lib\issuer_database;
  27  use enrol_lti\local\ltiadvantage\lib\launch_cache_session;
  28  use enrol_lti\local\ltiadvantage\repository\application_registration_repository;
  29  use enrol_lti\local\ltiadvantage\repository\deployment_repository;
  30  use enrol_lti\local\ltiadvantage\repository\resource_link_repository;
  31  use enrol_lti\local\ltiadvantage\repository\user_repository;
  32  use Packback\Lti1p3\LtiNamesRolesProvisioningService;
  33  use Packback\Lti1p3\LtiRegistration;
  34  use Packback\Lti1p3\LtiServiceConnector;
  35  use stdClass;
  36  
  37  /**
  38   * LTI Advantage-specific task responsible for syncing memberships from tool platforms with the tool.
  39   *
  40   * This task may gather members from a context-level service call, depending on whether a resource-level service call
  41   * (which is made first) was successful. Because of the context-wide memberships, and because each published resource
  42   * has per-resource access control (role assignments), this task only enrols user into the course, and does not assign
  43   * roles to resource/course contexts. Role assignment only takes place during a launch, via the tool_launch_service.
  44   *
  45   * @package    enrol_lti
  46   * @copyright  2021 Jake Dallimore <jrhdallimore@gmail.com>
  47   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  48   */
  49  class sync_members extends scheduled_task {
  50  
  51      /** @var array Array of user photos. */
  52      protected $userphotos = [];
  53  
  54      /** @var resource_link_repository $resourcelinkrepo for fetching resource_link instances.*/
  55      protected $resourcelinkrepo;
  56  
  57      /** @var application_registration_repository $appregistrationrepo for fetching application_registration instances.*/
  58      protected $appregistrationrepo;
  59  
  60      /** @var deployment_repository $deploymentrepo for fetching deployment instances. */
  61      protected $deploymentrepo;
  62  
  63      /** @var user_repository $userrepo for fetching and saving lti user information.*/
  64      protected $userrepo;
  65  
  66      /** @var issuer_database $issuerdb library specific registration DB required to create service connectors.*/
  67      protected $issuerdb;
  68  
  69      /**
  70       * Get the name for this task.
  71       *
  72       * @return string the name of the task.
  73       */
  74      public function get_name(): string {
  75          return get_string('tasksyncmembers', 'enrol_lti');
  76      }
  77  
  78      /**
  79       * Make a resource-link-level memberships call.
  80       *
  81       * @param nrps_info $nrps information about names and roles service endpoints and scopes.
  82       * @param LtiServiceConnector $sc a service connector object.
  83       * @param LtiRegistration $registration the registration
  84       * @param resource_link $resourcelink the resource link
  85       * @return array an array of members if found.
  86       */
  87      protected function get_resource_link_level_members(nrps_info $nrps, LtiServiceConnector $sc, LtiRegistration $registration,
  88              resource_link $resourcelink) {
  89  
  90          // Try a resource-link-level memberships call first, falling back to context-level if no members are found.
  91          $reslinkmembershipsurl = $nrps->get_context_memberships_url();
  92          $reslinkmembershipsurl->param('rlid', $resourcelink->get_resourcelinkid());
  93          $servicedata = [
  94              'context_memberships_url' => $reslinkmembershipsurl->out(false)
  95          ];
  96          $reslinklevelnrps = new LtiNamesRolesProvisioningService($sc, $registration, $servicedata);
  97  
  98          mtrace('Making resource-link-level memberships request');
  99          return $reslinklevelnrps->getMembers();
 100      }
 101  
 102      /**
 103       * Make a context-level memberships call.
 104       *
 105       * @param nrps_info $nrps information about names and roles service endpoints and scopes.
 106       * @param LtiServiceConnector $sc a service connector object.
 107       * @param LtiRegistration $registration the registration
 108       * @return array an array of members.
 109       */
 110      protected function get_context_level_members(nrps_info $nrps, LtiServiceConnector $sc, LtiRegistration $registration) {
 111          $clservicedata = [
 112              'context_memberships_url' => $nrps->get_context_memberships_url()->out(false)
 113          ];
 114          $contextlevelnrps = new LtiNamesRolesProvisioningService($sc, $registration, $clservicedata);
 115  
 116          return $contextlevelnrps->getMembers();
 117      }
 118  
 119      /**
 120       * Make the NRPS service call and fetch members based on the given resource link.
 121       *
 122       * Memberships will be retrieved by first trying the link-level memberships service first, falling back to calling
 123       * the context-level memberships service only if the link-level call fails.
 124       *
 125       * @param application_registration $appregistration an application registration instance.
 126       * @param resource_link $resourcelink a resourcelink instance.
 127       * @return array an array of members.
 128       */
 129      protected function get_members_from_resource_link(application_registration $appregistration,
 130              resource_link $resourcelink) {
 131  
 132          // Get a service worker for the corresponding application registration.
 133          $registration = $this->issuerdb->findRegistrationByIssuer(
 134              $appregistration->get_platformid()->out(false),
 135              $appregistration->get_clientid()
 136          );
 137          global $CFG;
 138          require_once($CFG->libdir . '/filelib.php');
 139          $sc = new LtiServiceConnector(new launch_cache_session(), new http_client(new \curl()));
 140  
 141          $nrps = $resourcelink->get_names_and_roles_service();
 142          try {
 143              $members = $this->get_resource_link_level_members($nrps, $sc, $registration, $resourcelink);
 144          } catch (\Exception $e) {
 145              mtrace('Link-level memberships request failed. Making context-level memberships request');
 146              $members = $this->get_context_level_members($nrps, $sc, $registration);
 147          }
 148  
 149          return $members;
 150      }
 151  
 152      /**
 153       * Performs the synchronisation of members.
 154       */
 155      public function execute() {
 156          if (!is_enabled_auth('lti')) {
 157              mtrace('Skipping task - ' . get_string('pluginnotenabled', 'auth', get_string('pluginname', 'auth_lti')));
 158              return;
 159          }
 160          if (!enrol_is_enabled('lti')) {
 161              mtrace('Skipping task - ' . get_string('enrolisdisabled', 'enrol_lti'));
 162              return;
 163          }
 164          $this->resourcelinkrepo = new resource_link_repository();
 165          $this->appregistrationrepo = new application_registration_repository();
 166          $this->deploymentrepo = new deployment_repository();
 167          $this->userrepo = new user_repository();
 168          $this->issuerdb = new issuer_database($this->appregistrationrepo, $this->deploymentrepo);
 169  
 170          $resources = helper::get_lti_tools(['status' => ENROL_INSTANCE_ENABLED, 'membersync' => 1,
 171              'ltiversion' => 'LTI-1p3']);
 172  
 173          foreach ($resources as $resource) {
 174              mtrace("Starting - Member sync for published resource '$resource->id' for course '$resource->courseid'.");
 175              $usercount = 0;
 176              $enrolcount = 0;
 177              $unenrolcount = 0;
 178              $syncedusers = [];
 179  
 180              // Get all resource_links for this shared resource.
 181              // This is how context/resource_link memberships calls will be made.
 182              $resourcelinks = $this->resourcelinkrepo->find_by_resource((int)$resource->id);
 183              foreach ($resourcelinks as $resourcelink) {
 184                  mtrace("Requesting names and roles for the resource link '{$resourcelink->get_id()}' for the resource" .
 185                      " '{$resource->id}'");
 186  
 187                  if (!$resourcelink->get_names_and_roles_service()) {
 188                      mtrace("Skipping - No names and roles service found.");
 189                      continue;
 190                  }
 191  
 192                  $appregistration = $this->appregistrationrepo->find_by_deployment(
 193                      $resourcelink->get_deploymentid()
 194                  );
 195                  if (!$appregistration) {
 196                      mtrace("Skipping - no corresponding application registration found.");
 197                      continue;
 198                  }
 199  
 200                  try {
 201                      $members = $this->get_members_from_resource_link($appregistration, $resourcelink);
 202                  } catch (\Exception $e) {
 203                      mtrace("Skipping - Names and Roles service request failed: {$e->getMessage()}.");
 204                      continue;
 205                  }
 206  
 207                  // Fetched members count.
 208                  $membercount = count($members);
 209                  $usercount += $membercount;
 210                  mtrace("$membercount members received.");
 211  
 212                  // Process member information.
 213                  [$rlenrolcount, $userids] = $this->sync_member_information($appregistration, $resource,
 214                      $resourcelink, $members);
 215                  $enrolcount += $rlenrolcount;
 216  
 217                  // Update the list of users synced for this shared resource or its context.
 218                  $syncedusers = array_unique(array_merge($syncedusers, $userids));
 219  
 220                  mtrace("Completed - Synced $membercount members for the resource link '{$resourcelink->get_id()}' ".
 221                      "for the resource '{$resource->id}'.\n");
 222  
 223                  // Sync unenrolments on a per-resource-link basis so we have fine grained control over unenrolments.
 224                  // If a resource link doesn't support NRPS, it will already have been skipped.
 225                  $unenrolcount += $this->sync_unenrol_resourcelink($resourcelink, $resource, $syncedusers);
 226              }
 227  
 228              mtrace("Completed - Synced members for tool '$resource->id' in the course '$resource->courseid'. " .
 229                  "Processed $usercount users; enrolled $enrolcount members; unenrolled $unenrolcount members.\n");
 230          }
 231  
 232          if (!empty($resources) && !empty($this->userphotos)) {
 233              // Sync the user profile photos.
 234              mtrace("Started - Syncing user profile images.");
 235              $countsyncedimages = $this->sync_profile_images();
 236              mtrace("Completed - Synced $countsyncedimages profile images.");
 237          }
 238      }
 239  
 240      /**
 241       * Process unenrolment of users for a given resource link and based on the list of recently synced users.
 242       *
 243       * @param resource_link $resourcelink the resource_link instance to which the $synced users pertains
 244       * @param stdClass $resource the resource object instance
 245       * @param array $syncedusers the array of recently synced users, who are not to be unenrolled.
 246       * @return int the number of unenrolled users.
 247       */
 248      protected function sync_unenrol_resourcelink(resource_link $resourcelink, stdClass $resource,
 249              array $syncedusers): int {
 250  
 251          if (!$this->should_sync_unenrol($resource->membersyncmode)) {
 252              return 0;
 253          }
 254          $ltiplugin = enrol_get_plugin('lti');
 255          $unenrolcount = 0;
 256  
 257          // Get all users for the resource_link instance.
 258          $linkusers = $this->userrepo->find_by_resource_link($resourcelink->get_id());
 259  
 260          foreach ($linkusers as $ltiuser) {
 261              if (!in_array($ltiuser->get_localid(), $syncedusers)) {
 262                  $instance = new stdClass();
 263                  $instance->id = $resource->enrolid;
 264                  $instance->courseid = $resource->courseid;
 265                  $instance->enrol = 'lti';
 266                  $ltiplugin->unenrol_user($instance, $ltiuser->get_localid());
 267                  $unenrolcount++;
 268              }
 269          }
 270          return $unenrolcount;
 271      }
 272  
 273      /**
 274       * Check whether the member has an instructor role or not.
 275       *
 276       * @param array $member
 277       * @return bool
 278       */
 279      protected function member_is_instructor(array $member): bool {
 280          // See: http://www.imsglobal.org/spec/lti/v1p3/#role-vocabularies.
 281          $memberroles = $member['roles'];
 282          if ($memberroles) {
 283              $adminroles = [
 284                  'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator',
 285                  'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator'
 286              ];
 287              $staffroles = [
 288                  'http://purl.imsglobal.org/vocab/lis/v2/membership#ContentDeveloper',
 289                  'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor',
 290                  'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistant',
 291                  'ContentDeveloper',
 292                  'Instructor',
 293                  'Instructor#TeachingAssistant'
 294              ];
 295              $instructorroles = array_merge($adminroles, $staffroles);
 296  
 297              foreach ($instructorroles as $validrole) {
 298                  if (in_array($validrole, $memberroles)) {
 299                      return true;
 300                  }
 301              }
 302          }
 303          return false;
 304      }
 305  
 306      /**
 307       * Method to determine whether to sync unenrolments or not.
 308       *
 309       * @param int $syncmode The shared resource's membersyncmode.
 310       * @return bool true if unenrolment should be synced, false if not.
 311       */
 312      protected function should_sync_unenrol($syncmode): bool {
 313          return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_UNENROL_MISSING;
 314      }
 315  
 316      /**
 317       * Method to determine whether to sync enrolments or not.
 318       *
 319       * @param int $syncmode The shared resource's membersyncmode.
 320       * @return bool true if enrolment should be synced, false if not.
 321       */
 322      protected function should_sync_enrol($syncmode): bool {
 323          return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_ENROL_NEW;
 324      }
 325  
 326      /**
 327       * Creates an lti user object from a member entry.
 328       *
 329       * @param stdClass $user the Moodle user record representing this member.
 330       * @param stdClass $resource the locally published resource record, used for setting user defaults.
 331       * @param resource_link $resourcelink the resource_link instance.
 332       * @param array $member the member information from the NRPS service call.
 333       * @return user the lti user instance.
 334       */
 335      protected function ltiuser_from_member(stdClass $user, stdClass $resource,
 336              resource_link $resourcelink, array $member): user {
 337  
 338          if (!$ltiuser = $this->userrepo->find_single_user_by_resource($user->id, $resource->id)) {
 339              // New user, so create them.
 340              $ltiuser = user::create(
 341                  $resourcelink->get_resourceid(),
 342                  $user->id,
 343                  $resourcelink->get_deploymentid(),
 344                  $member['user_id'],
 345                  $resource->lang,
 346                  $resource->timezone,
 347                  $resource->city ?? '',
 348                  $resource->country ?? '',
 349                  $resource->institution ?? '',
 350                  $resource->maildisplay
 351              );
 352          }
 353          $ltiuser->set_lastaccess(time());
 354          return $ltiuser;
 355      }
 356  
 357      /**
 358       * Performs synchronisation of member information and enrolments.
 359       *
 360       * @param application_registration $appregistration the application_registration instance.
 361       * @param stdClass $resource the enrol_lti_tools resource information.
 362       * @param resource_link $resourcelink the resource_link instance.
 363       * @param user[] $members an array of members to sync.
 364       * @return array An array containing the counts of enrolled users and a list of userids.
 365       */
 366      protected function sync_member_information(application_registration $appregistration, stdClass $resource,
 367              resource_link $resourcelink, array $members): array {
 368  
 369          $enrolcount = 0;
 370          $userids = [];
 371  
 372          // Get the verified legacy consumer key, if mapped, from the resource link's tool deployment.
 373          // This will be used to locate legacy user accounts and link them to LTI 1.3 users.
 374          // A launch must have been made in order to get the legacy consumer key from the lti1p1 migration claim.
 375          $deployment = $this->deploymentrepo->find($resourcelink->get_deploymentid());
 376          $legacyconsumerkey = $deployment->get_legacy_consumer_key() ?? '';
 377  
 378          foreach ($members as $member) {
 379              $auth = get_auth_plugin('lti');
 380              if ($auth->get_user_binding($appregistration->get_platformid()->out(false), $member['user_id'])) {
 381                  // Use is bound already, so we can update them.
 382                  $user = $auth->find_or_create_user_from_membership($member, $appregistration->get_platformid()->out(false));
 383                  if ($user->auth != 'lti') {
 384                      mtrace("Skipped profile sync for user '$user->id'. The user does not belong to the LTI auth method.");
 385                  }
 386              } else {
 387                  // Not bound, so defer to the role-based provisioning mode for the resource.
 388                  $provisioningmode = $this->member_is_instructor($member) ? $resource->provisioningmodeinstructor :
 389                      $resource->provisioningmodelearner;
 390                  switch ($provisioningmode) {
 391                      case \auth_plugin_lti::PROVISIONING_MODE_AUTO_ONLY:
 392                          // Automatic provisioning - this will create a user account and log the user in.
 393                          $user = $auth->find_or_create_user_from_membership($member, $appregistration->get_platformid()->out(false),
 394                              $legacyconsumerkey);
 395                          break;
 396                      case \auth_plugin_lti::PROVISIONING_MODE_PROMPT_NEW_EXISTING:
 397                      case \auth_plugin_lti::PROVISIONING_MODE_PROMPT_EXISTING_ONLY:
 398                      default:
 399                          mtrace("Skipping account creation for member '{$member['user_id']}'. This member is not eligible for ".
 400                              "automatic creation due to the current account provisioning mode.");
 401                          continue 2;
 402                  }
 403              }
 404  
 405              $ltiuser = $this->ltiuser_from_member($user, $resource, $resourcelink, $member);
 406  
 407              if ($this->should_sync_enrol($resource->membersyncmode)) {
 408  
 409                  $ltiuser->set_resourcelinkid($resourcelink->get_id());
 410                  $ltiuser = $this->userrepo->save($ltiuser);
 411                  if ($user->auth != 'lti') {
 412                      mtrace("Skipped picture sync for user '$user->id'. The user does not belong to the LTI auth method.");
 413                  } else {
 414                      if (isset($member['picture'])) {
 415                          $this->userphotos[$ltiuser->get_localid()] = $member['picture'];
 416                      }
 417                  }
 418  
 419                  // Enrol the user in the course.
 420                  if (helper::enrol_user($resource, $ltiuser->get_localid()) === helper::ENROLMENT_SUCCESSFUL) {
 421                      $enrolcount++;
 422                  }
 423              }
 424  
 425              // If the member has been created, or exists locally already, mark them as valid so as to not unenrol them
 426              // when syncing memberships for shared resources configured as either MEMBER_SYNC_ENROL_AND_UNENROL or
 427              // MEMBER_SYNC_UNENROL_MISSING.
 428              $userids[] = $user->id;
 429          }
 430  
 431          return [$enrolcount, $userids];
 432      }
 433  
 434      /**
 435       * Performs synchronisation of user profile images.
 436       *
 437       * @return int the count of synced photos.
 438       */
 439      protected function sync_profile_images(): int {
 440          $counter = 0;
 441          foreach ($this->userphotos as $userid => $url) {
 442              if ($url) {
 443                  $result = helper::update_user_profile_image($userid, $url);
 444                  if ($result === helper::PROFILE_IMAGE_UPDATE_SUCCESSFUL) {
 445                      $counter++;
 446                      mtrace("Profile image successfully downloaded and created for user '$userid' from $url.");
 447                  } else {
 448                      mtrace($result);
 449                  }
 450              }
 451          }
 452          return $counter;
 453      }
 454  }