See Release Notes
Long Term Support Release
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body