See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 39 and 401] [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 /** 18 * LDAP enrolment plugin implementation. 19 * 20 * This plugin synchronises enrolment and roles with a LDAP server. 21 * 22 * @package enrol_ldap 23 * @author Iñaki Arenaza - based on code by Martin Dougiamas, Martin Langhoff and others 24 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} 25 * @copyright 2010 Iñaki Arenaza <iarenaza@eps.mondragon.edu> 26 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 27 */ 28 29 defined('MOODLE_INTERNAL') || die(); 30 31 class enrol_ldap_plugin extends enrol_plugin { 32 protected $enrol_localcoursefield = 'idnumber'; 33 protected $enroltype = 'enrol_ldap'; 34 protected $errorlogtag = '[ENROL LDAP] '; 35 36 /** 37 * The object class to use when finding users. 38 * 39 * @var string $userobjectclass 40 */ 41 protected $userobjectclass; 42 43 /** 44 * Constructor for the plugin. In addition to calling the parent 45 * constructor, we define and 'fix' some settings depending on the 46 * real settings the admin defined. 47 */ 48 public function __construct() { 49 global $CFG; 50 require_once($CFG->libdir.'/ldaplib.php'); 51 52 // Do our own stuff to fix the config (it's easier to do it 53 // here than using the admin settings infrastructure). We 54 // don't call $this->set_config() for any of the 'fixups' 55 // (except the objectclass, as it's critical) because the user 56 // didn't specify any values and relied on the default values 57 // defined for the user type she chose. 58 $this->load_config(); 59 60 // Make sure we get sane defaults for critical values. 61 $this->config->ldapencoding = $this->get_config('ldapencoding', 'utf-8'); 62 $this->config->user_type = $this->get_config('user_type', 'default'); 63 64 $ldap_usertypes = ldap_supported_usertypes(); 65 $this->config->user_type_name = $ldap_usertypes[$this->config->user_type]; 66 unset($ldap_usertypes); 67 68 $default = ldap_getdefaults(); 69 70 // The objectclass in the defaults is for a user. 71 // This will be required later, but enrol_ldap uses 'objectclass' for its group objectclass. 72 // Save the normalised user objectclass for later. 73 $this->userobjectclass = ldap_normalise_objectclass($default['objectclass'][$this->get_config('user_type')]); 74 75 // Remove the objectclass default, as the values specified there are for users, and we are dealing with groups here. 76 unset($default['objectclass']); 77 78 // Use defaults if values not given. Dont use this->get_config() 79 // here to be able to check for 0 and false values too. 80 foreach ($default as $key => $value) { 81 // Watch out - 0, false are correct values too, so we can't use $this->get_config() 82 if (!isset($this->config->{$key}) or $this->config->{$key} == '') { 83 $this->config->{$key} = $value[$this->config->user_type]; 84 } 85 } 86 87 // Normalise the objectclass used for groups. 88 if (empty($this->config->objectclass)) { 89 // No objectclass set yet - set a default class. 90 $this->config->objectclass = ldap_normalise_objectclass(null, '*'); 91 $this->set_config('objectclass', $this->config->objectclass); 92 } else { 93 $objectclass = ldap_normalise_objectclass($this->config->objectclass); 94 if ($objectclass !== $this->config->objectclass) { 95 // The objectclass was changed during normalisation. 96 // Save it in config, and update the local copy of config. 97 $this->set_config('objectclass', $objectclass); 98 $this->config->objectclass = $objectclass; 99 } 100 } 101 } 102 103 /** 104 * Is it possible to delete enrol instance via standard UI? 105 * 106 * @param object $instance 107 * @return bool 108 */ 109 public function can_delete_instance($instance) { 110 $context = context_course::instance($instance->courseid); 111 if (!has_capability('enrol/ldap:manage', $context)) { 112 return false; 113 } 114 115 if (!enrol_is_enabled('ldap')) { 116 return true; 117 } 118 119 if (!$this->get_config('ldap_host') or !$this->get_config('objectclass') or !$this->get_config('course_idnumber')) { 120 return true; 121 } 122 123 // TODO: connect to external system and make sure no users are to be enrolled in this course 124 return false; 125 } 126 127 /** 128 * Is it possible to hide/show enrol instance via standard UI? 129 * 130 * @param stdClass $instance 131 * @return bool 132 */ 133 public function can_hide_show_instance($instance) { 134 $context = context_course::instance($instance->courseid); 135 return has_capability('enrol/ldap:manage', $context); 136 } 137 138 /** 139 * Forces synchronisation of user enrolments with LDAP server. 140 * It creates courses if the plugin is configured to do so. 141 * 142 * @param object $user user record 143 * @return void 144 */ 145 public function sync_user_enrolments($user) { 146 global $DB; 147 148 // Do not try to print anything to the output because this method is called during interactive login. 149 if (PHPUNIT_TEST) { 150 $trace = new null_progress_trace(); 151 } else { 152 $trace = new error_log_progress_trace($this->errorlogtag); 153 } 154 155 if (!$this->ldap_connect($trace)) { 156 $trace->finished(); 157 return; 158 } 159 160 if (!is_object($user) or !property_exists($user, 'id')) { 161 throw new coding_exception('Invalid $user parameter in sync_user_enrolments()'); 162 } 163 164 if (!property_exists($user, 'idnumber')) { 165 debugging('Invalid $user parameter in sync_user_enrolments(), missing idnumber'); 166 $user = $DB->get_record('user', array('id'=>$user->id)); 167 } 168 169 // We may need a lot of memory here 170 core_php_time_limit::raise(); 171 raise_memory_limit(MEMORY_HUGE); 172 173 // Get enrolments for each type of role. 174 $roles = get_all_roles(); 175 $enrolments = array(); 176 foreach($roles as $role) { 177 // Get external enrolments according to LDAP server 178 $enrolments[$role->id]['ext'] = $this->find_ext_enrolments($user->idnumber, $role); 179 180 // Get the list of current user enrolments that come from LDAP 181 $sql= "SELECT e.courseid, ue.status, e.id as enrolid, c.shortname 182 FROM {user} u 183 JOIN {role_assignments} ra ON (ra.userid = u.id AND ra.component = 'enrol_ldap' AND ra.roleid = :roleid) 184 JOIN {user_enrolments} ue ON (ue.userid = u.id AND ue.enrolid = ra.itemid) 185 JOIN {enrol} e ON (e.id = ue.enrolid) 186 JOIN {course} c ON (c.id = e.courseid) 187 WHERE u.deleted = 0 AND u.id = :userid"; 188 $params = array ('roleid'=>$role->id, 'userid'=>$user->id); 189 $enrolments[$role->id]['current'] = $DB->get_records_sql($sql, $params); 190 } 191 192 $ignorehidden = $this->get_config('ignorehiddencourses'); 193 $courseidnumber = $this->get_config('course_idnumber'); 194 foreach($roles as $role) { 195 foreach ($enrolments[$role->id]['ext'] as $enrol) { 196 $course_ext_id = $enrol[$courseidnumber][0]; 197 if (empty($course_ext_id)) { 198 $trace->output(get_string('extcourseidinvalid', 'enrol_ldap')); 199 continue; // Next; skip this one! 200 } 201 202 // Create the course if required 203 $course = $DB->get_record('course', array($this->enrol_localcoursefield=>$course_ext_id)); 204 if (empty($course)) { // Course doesn't exist 205 if ($this->get_config('autocreate')) { // Autocreate 206 $trace->output(get_string('createcourseextid', 'enrol_ldap', array('courseextid'=>$course_ext_id))); 207 if (!$newcourseid = $this->create_course($enrol, $trace)) { 208 continue; 209 } 210 $course = $DB->get_record('course', array('id'=>$newcourseid)); 211 } else { 212 $trace->output(get_string('createnotcourseextid', 'enrol_ldap', array('courseextid'=>$course_ext_id))); 213 continue; // Next; skip this one! 214 } 215 } 216 217 // Deal with enrolment in the moodle db 218 // Add necessary enrol instance if not present yet; 219 $sql = "SELECT c.id, c.visible, e.id as enrolid 220 FROM {course} c 221 JOIN {enrol} e ON (e.courseid = c.id AND e.enrol = 'ldap') 222 WHERE c.id = :courseid"; 223 $params = array('courseid'=>$course->id); 224 if (!($course_instance = $DB->get_record_sql($sql, $params, IGNORE_MULTIPLE))) { 225 $course_instance = new stdClass(); 226 $course_instance->id = $course->id; 227 $course_instance->visible = $course->visible; 228 $course_instance->enrolid = $this->add_instance($course_instance); 229 } 230 231 if (!$instance = $DB->get_record('enrol', array('id'=>$course_instance->enrolid))) { 232 continue; // Weird; skip this one. 233 } 234 235 if ($ignorehidden && !$course_instance->visible) { 236 continue; 237 } 238 239 if (empty($enrolments[$role->id]['current'][$course->id])) { 240 // Enrol the user in the given course, with that role. 241 $this->enrol_user($instance, $user->id, $role->id); 242 // Make sure we set the enrolment status to active. If the user wasn't 243 // previously enrolled to the course, enrol_user() sets it. But if we 244 // configured the plugin to suspend the user enrolments _AND_ remove 245 // the role assignments on external unenrol, then enrol_user() doesn't 246 // set it back to active on external re-enrolment. So set it 247 // unconditionnally to cover both cases. 248 $DB->set_field('user_enrolments', 'status', ENROL_USER_ACTIVE, array('enrolid'=>$instance->id, 'userid'=>$user->id)); 249 $trace->output(get_string('enroluser', 'enrol_ldap', 250 array('user_username'=> $user->username, 251 'course_shortname'=>$course->shortname, 252 'course_id'=>$course->id))); 253 } else { 254 if ($enrolments[$role->id]['current'][$course->id]->status == ENROL_USER_SUSPENDED) { 255 // Reenable enrolment that was previously disabled. Enrolment refreshed 256 $DB->set_field('user_enrolments', 'status', ENROL_USER_ACTIVE, array('enrolid'=>$instance->id, 'userid'=>$user->id)); 257 $trace->output(get_string('enroluserenable', 'enrol_ldap', 258 array('user_username'=> $user->username, 259 'course_shortname'=>$course->shortname, 260 'course_id'=>$course->id))); 261 } 262 } 263 264 // Remove this course from the current courses, to be able to detect 265 // which current courses should be unenroled from when we finish processing 266 // external enrolments. 267 unset($enrolments[$role->id]['current'][$course->id]); 268 } 269 270 // Deal with unenrolments. 271 $transaction = $DB->start_delegated_transaction(); 272 foreach ($enrolments[$role->id]['current'] as $course) { 273 $context = context_course::instance($course->courseid); 274 $instance = $DB->get_record('enrol', array('id'=>$course->enrolid)); 275 switch ($this->get_config('unenrolaction')) { 276 case ENROL_EXT_REMOVED_UNENROL: 277 $this->unenrol_user($instance, $user->id); 278 $trace->output(get_string('extremovedunenrol', 'enrol_ldap', 279 array('user_username'=> $user->username, 280 'course_shortname'=>$course->shortname, 281 'course_id'=>$course->courseid))); 282 break; 283 case ENROL_EXT_REMOVED_KEEP: 284 // Keep - only adding enrolments 285 break; 286 case ENROL_EXT_REMOVED_SUSPEND: 287 if ($course->status != ENROL_USER_SUSPENDED) { 288 $DB->set_field('user_enrolments', 'status', ENROL_USER_SUSPENDED, array('enrolid'=>$instance->id, 'userid'=>$user->id)); 289 $trace->output(get_string('extremovedsuspend', 'enrol_ldap', 290 array('user_username'=> $user->username, 291 'course_shortname'=>$course->shortname, 292 'course_id'=>$course->courseid))); 293 } 294 break; 295 case ENROL_EXT_REMOVED_SUSPENDNOROLES: 296 if ($course->status != ENROL_USER_SUSPENDED) { 297 $DB->set_field('user_enrolments', 'status', ENROL_USER_SUSPENDED, array('enrolid'=>$instance->id, 'userid'=>$user->id)); 298 } 299 role_unassign_all(array('contextid'=>$context->id, 'userid'=>$user->id, 'component'=>'enrol_ldap', 'itemid'=>$instance->id)); 300 $trace->output(get_string('extremovedsuspendnoroles', 'enrol_ldap', 301 array('user_username'=> $user->username, 302 'course_shortname'=>$course->shortname, 303 'course_id'=>$course->courseid))); 304 break; 305 } 306 } 307 $transaction->allow_commit(); 308 } 309 310 $this->ldap_close(); 311 312 $trace->finished(); 313 } 314 315 /** 316 * Forces synchronisation of all enrolments with LDAP server. 317 * It creates courses if the plugin is configured to do so. 318 * 319 * @param progress_trace $trace 320 * @param int|null $onecourse limit sync to one course->id, null if all courses 321 * @return void 322 */ 323 public function sync_enrolments(progress_trace $trace, $onecourse = null) { 324 global $CFG, $DB; 325 326 if (!$this->ldap_connect($trace)) { 327 $trace->finished(); 328 return; 329 } 330 331 $ldap_pagedresults = ldap_paged_results_supported($this->get_config('ldap_version'), $this->ldapconnection); 332 333 // we may need a lot of memory here 334 core_php_time_limit::raise(); 335 raise_memory_limit(MEMORY_HUGE); 336 337 $oneidnumber = null; 338 if ($onecourse) { 339 if (!$course = $DB->get_record('course', array('id'=>$onecourse), 'id,'.$this->enrol_localcoursefield)) { 340 // Course does not exist, nothing to do. 341 $trace->output("Requested course $onecourse does not exist, no sync performed."); 342 $trace->finished(); 343 return; 344 } 345 if (empty($course->{$this->enrol_localcoursefield})) { 346 $trace->output("Requested course $onecourse does not have {$this->enrol_localcoursefield}, no sync performed."); 347 $trace->finished(); 348 return; 349 } 350 $oneidnumber = ldap_filter_addslashes(core_text::convert($course->idnumber, 'utf-8', $this->get_config('ldapencoding'))); 351 } 352 353 // Get enrolments for each type of role. 354 $roles = get_all_roles(); 355 $enrolments = array(); 356 foreach($roles as $role) { 357 // Get all contexts 358 $ldap_contexts = explode(';', $this->config->{'contexts_role'.$role->id}); 359 360 // Get all the fields we will want for the potential course creation 361 // as they are light. Don't get membership -- potentially a lot of data. 362 $ldap_fields_wanted = array('dn', $this->config->course_idnumber); 363 if (!empty($this->config->course_fullname)) { 364 array_push($ldap_fields_wanted, $this->config->course_fullname); 365 } 366 if (!empty($this->config->course_shortname)) { 367 array_push($ldap_fields_wanted, $this->config->course_shortname); 368 } 369 if (!empty($this->config->course_summary)) { 370 array_push($ldap_fields_wanted, $this->config->course_summary); 371 } 372 array_push($ldap_fields_wanted, $this->config->{'memberattribute_role'.$role->id}); 373 374 // Define the search pattern 375 $ldap_search_pattern = $this->config->objectclass; 376 377 if ($oneidnumber !== null) { 378 $ldap_search_pattern = "(&$ldap_search_pattern({$this->config->course_idnumber}=$oneidnumber))"; 379 } 380 381 $ldap_cookie = ''; 382 $servercontrols = array(); 383 foreach ($ldap_contexts as $ldap_context) { 384 $ldap_context = trim($ldap_context); 385 if (empty($ldap_context)) { 386 continue; // Next; 387 } 388 389 $flat_records = array(); 390 do { 391 if ($ldap_pagedresults) { 392 $servercontrols = array(array( 393 'oid' => LDAP_CONTROL_PAGEDRESULTS, 'value' => array( 394 'size' => $this->config->pagesize, 'cookie' => $ldap_cookie))); 395 } 396 397 if ($this->config->course_search_sub) { 398 // Use ldap_search to find first user from subtree 399 $ldap_result = @ldap_search($this->ldapconnection, $ldap_context, 400 $ldap_search_pattern, $ldap_fields_wanted, 401 0, -1, -1, LDAP_DEREF_NEVER, $servercontrols); 402 } else { 403 // Search only in this context 404 $ldap_result = @ldap_list($this->ldapconnection, $ldap_context, 405 $ldap_search_pattern, $ldap_fields_wanted, 406 0, -1, -1, LDAP_DEREF_NEVER, $servercontrols); 407 } 408 if (!$ldap_result) { 409 continue; // Next 410 } 411 412 if ($ldap_pagedresults) { 413 // Get next server cookie to know if we'll need to continue searching. 414 $ldap_cookie = ''; 415 // Get next cookie from controls. 416 ldap_parse_result($this->ldapconnection, $ldap_result, $errcode, $matcheddn, 417 $errmsg, $referrals, $controls); 418 if (isset($controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'])) { 419 $ldap_cookie = $controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie']; 420 } 421 } 422 423 // Check and push results 424 $records = ldap_get_entries($this->ldapconnection, $ldap_result); 425 426 // LDAP libraries return an odd array, really. fix it: 427 for ($c = 0; $c < $records['count']; $c++) { 428 array_push($flat_records, $records[$c]); 429 } 430 // Free some mem 431 unset($records); 432 } while ($ldap_pagedresults && !empty($ldap_cookie)); 433 434 // If LDAP paged results were used, the current connection must be completely 435 // closed and a new one created, to work without paged results from here on. 436 if ($ldap_pagedresults) { 437 $this->ldap_close(); 438 $this->ldap_connect($trace); 439 } 440 441 if (count($flat_records)) { 442 $ignorehidden = $this->get_config('ignorehiddencourses'); 443 foreach($flat_records as $course) { 444 $course = array_change_key_case($course, CASE_LOWER); 445 $idnumber = $course[$this->config->course_idnumber][0]; 446 $trace->output(get_string('synccourserole', 'enrol_ldap', array('idnumber'=>$idnumber, 'role_shortname'=>$role->shortname))); 447 448 // Does the course exist in moodle already? 449 $course_obj = $DB->get_record('course', array($this->enrol_localcoursefield=>$idnumber)); 450 if (empty($course_obj)) { // Course doesn't exist 451 if ($this->get_config('autocreate')) { // Autocreate 452 $trace->output(get_string('createcourseextid', 'enrol_ldap', array('courseextid'=>$idnumber))); 453 if (!$newcourseid = $this->create_course($course, $trace)) { 454 continue; 455 } 456 $course_obj = $DB->get_record('course', array('id'=>$newcourseid)); 457 } else { 458 $trace->output(get_string('createnotcourseextid', 'enrol_ldap', array('courseextid'=>$idnumber))); 459 continue; // Next; skip this one! 460 } 461 } else { // Check if course needs update & update as needed. 462 $this->update_course($course_obj, $course, $trace); 463 } 464 465 // Enrol & unenrol 466 467 // Pull the ldap membership into a nice array 468 // this is an odd array -- mix of hash and array -- 469 $ldapmembers = array(); 470 471 if (property_exists($this->config, 'memberattribute_role'.$role->id) 472 && !empty($this->config->{'memberattribute_role'.$role->id}) 473 && !empty($course[$this->config->{'memberattribute_role'.$role->id}])) { // May have no membership! 474 475 $ldapmembers = $course[$this->config->{'memberattribute_role'.$role->id}]; 476 unset($ldapmembers['count']); // Remove oddity ;) 477 478 // If we have enabled nested groups, we need to expand 479 // the groups to get the real user list. We need to do 480 // this before dealing with 'memberattribute_isdn'. 481 if ($this->config->nested_groups) { 482 $users = array(); 483 foreach ($ldapmembers as $ldapmember) { 484 $grpusers = $this->ldap_explode_group($ldapmember, 485 $this->config->{'memberattribute_role'.$role->id}); 486 487 $users = array_merge($users, $grpusers); 488 } 489 $ldapmembers = array_unique($users); // There might be duplicates. 490 } 491 492 // Deal with the case where the member attribute holds distinguished names, 493 // but only if the user attribute is not a distinguished name itself. 494 if ($this->config->memberattribute_isdn 495 && ($this->config->idnumber_attribute !== 'dn') 496 && ($this->config->idnumber_attribute !== 'distinguishedname')) { 497 // We need to retrieve the idnumber for all the users in $ldapmembers, 498 // as the idnumber does not match their dn and we get dn's from membership. 499 $memberidnumbers = array(); 500 foreach ($ldapmembers as $ldapmember) { 501 $result = ldap_read($this->ldapconnection, $ldapmember, $this->userobjectclass, 502 array($this->config->idnumber_attribute)); 503 $entry = ldap_first_entry($this->ldapconnection, $result); 504 $values = ldap_get_values($this->ldapconnection, $entry, $this->config->idnumber_attribute); 505 array_push($memberidnumbers, $values[0]); 506 } 507 508 $ldapmembers = $memberidnumbers; 509 } 510 } 511 512 // Prune old ldap enrolments 513 // hopefully they'll fit in the max buffer size for the RDBMS 514 $sql= "SELECT u.id as userid, u.username, ue.status, 515 ra.contextid, ra.itemid as instanceid 516 FROM {user} u 517 JOIN {role_assignments} ra ON (ra.userid = u.id AND ra.component = 'enrol_ldap' AND ra.roleid = :roleid) 518 JOIN {user_enrolments} ue ON (ue.userid = u.id AND ue.enrolid = ra.itemid) 519 JOIN {enrol} e ON (e.id = ue.enrolid) 520 WHERE u.deleted = 0 AND e.courseid = :courseid "; 521 $params = array('roleid'=>$role->id, 'courseid'=>$course_obj->id); 522 $context = context_course::instance($course_obj->id); 523 if (!empty($ldapmembers)) { 524 list($ldapml, $params2) = $DB->get_in_or_equal($ldapmembers, SQL_PARAMS_NAMED, 'm', false); 525 $sql .= "AND u.idnumber $ldapml"; 526 $params = array_merge($params, $params2); 527 unset($params2); 528 } else { 529 $shortname = format_string($course_obj->shortname, true, array('context' => $context)); 530 $trace->output(get_string('emptyenrolment', 'enrol_ldap', 531 array('role_shortname'=> $role->shortname, 532 'course_shortname' => $shortname))); 533 } 534 $todelete = $DB->get_records_sql($sql, $params); 535 536 if (!empty($todelete)) { 537 $transaction = $DB->start_delegated_transaction(); 538 foreach ($todelete as $row) { 539 $instance = $DB->get_record('enrol', array('id'=>$row->instanceid)); 540 switch ($this->get_config('unenrolaction')) { 541 case ENROL_EXT_REMOVED_UNENROL: 542 $this->unenrol_user($instance, $row->userid); 543 $trace->output(get_string('extremovedunenrol', 'enrol_ldap', 544 array('user_username'=> $row->username, 545 'course_shortname'=>$course_obj->shortname, 546 'course_id'=>$course_obj->id))); 547 break; 548 case ENROL_EXT_REMOVED_KEEP: 549 // Keep - only adding enrolments 550 break; 551 case ENROL_EXT_REMOVED_SUSPEND: 552 if ($row->status != ENROL_USER_SUSPENDED) { 553 $DB->set_field('user_enrolments', 'status', ENROL_USER_SUSPENDED, array('enrolid'=>$instance->id, 'userid'=>$row->userid)); 554 $trace->output(get_string('extremovedsuspend', 'enrol_ldap', 555 array('user_username'=> $row->username, 556 'course_shortname'=>$course_obj->shortname, 557 'course_id'=>$course_obj->id))); 558 } 559 break; 560 case ENROL_EXT_REMOVED_SUSPENDNOROLES: 561 if ($row->status != ENROL_USER_SUSPENDED) { 562 $DB->set_field('user_enrolments', 'status', ENROL_USER_SUSPENDED, array('enrolid'=>$instance->id, 'userid'=>$row->userid)); 563 } 564 role_unassign_all(array('contextid'=>$row->contextid, 'userid'=>$row->userid, 'component'=>'enrol_ldap', 'itemid'=>$instance->id)); 565 $trace->output(get_string('extremovedsuspendnoroles', 'enrol_ldap', 566 array('user_username'=> $row->username, 567 'course_shortname'=>$course_obj->shortname, 568 'course_id'=>$course_obj->id))); 569 break; 570 } 571 } 572 $transaction->allow_commit(); 573 } 574 575 // Insert current enrolments 576 // bad we can't do INSERT IGNORE with postgres... 577 578 // Add necessary enrol instance if not present yet; 579 $sql = "SELECT c.id, c.visible, e.id as enrolid 580 FROM {course} c 581 JOIN {enrol} e ON (e.courseid = c.id AND e.enrol = 'ldap') 582 WHERE c.id = :courseid"; 583 $params = array('courseid'=>$course_obj->id); 584 if (!($course_instance = $DB->get_record_sql($sql, $params, IGNORE_MULTIPLE))) { 585 $course_instance = new stdClass(); 586 $course_instance->id = $course_obj->id; 587 $course_instance->visible = $course_obj->visible; 588 $course_instance->enrolid = $this->add_instance($course_instance); 589 } 590 591 if (!$instance = $DB->get_record('enrol', array('id'=>$course_instance->enrolid))) { 592 continue; // Weird; skip this one. 593 } 594 595 if ($ignorehidden && !$course_instance->visible) { 596 continue; 597 } 598 599 $transaction = $DB->start_delegated_transaction(); 600 foreach ($ldapmembers as $ldapmember) { 601 $sql = 'SELECT id,username,1 FROM {user} WHERE idnumber = ? AND deleted = 0'; 602 $member = $DB->get_record_sql($sql, array($ldapmember)); 603 if(empty($member) || empty($member->id)){ 604 $trace->output(get_string('couldnotfinduser', 'enrol_ldap', $ldapmember)); 605 continue; 606 } 607 608 $sql= "SELECT ue.status 609 FROM {user_enrolments} ue 610 JOIN {enrol} e ON (e.id = ue.enrolid AND e.enrol = 'ldap') 611 WHERE e.courseid = :courseid AND ue.userid = :userid"; 612 $params = array('courseid'=>$course_obj->id, 'userid'=>$member->id); 613 $userenrolment = $DB->get_record_sql($sql, $params, IGNORE_MULTIPLE); 614 615 if (empty($userenrolment)) { 616 $this->enrol_user($instance, $member->id, $role->id); 617 // Make sure we set the enrolment status to active. If the user wasn't 618 // previously enrolled to the course, enrol_user() sets it. But if we 619 // configured the plugin to suspend the user enrolments _AND_ remove 620 // the role assignments on external unenrol, then enrol_user() doesn't 621 // set it back to active on external re-enrolment. So set it 622 // unconditionally to cover both cases. 623 $DB->set_field('user_enrolments', 'status', ENROL_USER_ACTIVE, array('enrolid'=>$instance->id, 'userid'=>$member->id)); 624 $trace->output(get_string('enroluser', 'enrol_ldap', 625 array('user_username'=> $member->username, 626 'course_shortname'=>$course_obj->shortname, 627 'course_id'=>$course_obj->id))); 628 629 } else { 630 if (!$DB->record_exists('role_assignments', array('roleid'=>$role->id, 'userid'=>$member->id, 'contextid'=>$context->id, 'component'=>'enrol_ldap', 'itemid'=>$instance->id))) { 631 // This happens when reviving users or when user has multiple roles in one course. 632 $context = context_course::instance($course_obj->id); 633 role_assign($role->id, $member->id, $context->id, 'enrol_ldap', $instance->id); 634 $trace->output("Assign role to user '$member->username' in course '$course_obj->shortname ($course_obj->id)'"); 635 } 636 if ($userenrolment->status == ENROL_USER_SUSPENDED) { 637 // Reenable enrolment that was previously disabled. Enrolment refreshed 638 $DB->set_field('user_enrolments', 'status', ENROL_USER_ACTIVE, array('enrolid'=>$instance->id, 'userid'=>$member->id)); 639 $trace->output(get_string('enroluserenable', 'enrol_ldap', 640 array('user_username'=> $member->username, 641 'course_shortname'=>$course_obj->shortname, 642 'course_id'=>$course_obj->id))); 643 } 644 } 645 } 646 $transaction->allow_commit(); 647 } 648 } 649 } 650 } 651 @$this->ldap_close(); 652 $trace->finished(); 653 } 654 655 /** 656 * Connect to the LDAP server, using the plugin configured 657 * settings. It's actually a wrapper around ldap_connect_moodle() 658 * 659 * @param progress_trace $trace 660 * @return bool success 661 */ 662 protected function ldap_connect(progress_trace $trace = null) { 663 global $CFG; 664 require_once($CFG->libdir.'/ldaplib.php'); 665 666 if (isset($this->ldapconnection)) { 667 return true; 668 } 669 670 if ($ldapconnection = ldap_connect_moodle($this->get_config('host_url'), $this->get_config('ldap_version'), 671 $this->get_config('user_type'), $this->get_config('bind_dn'), 672 $this->get_config('bind_pw'), $this->get_config('opt_deref'), 673 $debuginfo, $this->get_config('start_tls'))) { 674 $this->ldapconnection = $ldapconnection; 675 return true; 676 } 677 678 if ($trace) { 679 $trace->output($debuginfo); 680 } else { 681 error_log($this->errorlogtag.$debuginfo); 682 } 683 684 return false; 685 } 686 687 /** 688 * Disconnects from a LDAP server 689 * 690 */ 691 protected function ldap_close() { 692 if (isset($this->ldapconnection)) { 693 @ldap_close($this->ldapconnection); 694 $this->ldapconnection = null; 695 } 696 return; 697 } 698 699 /** 700 * Return multidimensional array with details of user courses (at 701 * least dn and idnumber). 702 * 703 * @param string $memberuid user idnumber (without magic quotes). 704 * @param object role is a record from the mdl_role table. 705 * @return array 706 */ 707 protected function find_ext_enrolments($memberuid, $role) { 708 global $CFG; 709 require_once($CFG->libdir.'/ldaplib.php'); 710 711 if (empty($memberuid)) { 712 // No "idnumber" stored for this user, so no LDAP enrolments 713 return array(); 714 } 715 716 $ldap_contexts = trim($this->get_config('contexts_role'.$role->id)); 717 if (empty($ldap_contexts)) { 718 // No role contexts, so no LDAP enrolments 719 return array(); 720 } 721 722 $extmemberuid = core_text::convert($memberuid, 'utf-8', $this->get_config('ldapencoding')); 723 724 if($this->get_config('memberattribute_isdn')) { 725 if (!($extmemberuid = $this->ldap_find_userdn($extmemberuid))) { 726 return array(); 727 } 728 } 729 730 $ldap_search_pattern = ''; 731 if($this->get_config('nested_groups')) { 732 $usergroups = $this->ldap_find_user_groups($extmemberuid); 733 if(count($usergroups) > 0) { 734 foreach ($usergroups as $group) { 735 $group = ldap_filter_addslashes($group); 736 $ldap_search_pattern .= '('.$this->get_config('memberattribute_role'.$role->id).'='.$group.')'; 737 } 738 } 739 } 740 741 // Default return value 742 $courses = array(); 743 744 // Get all the fields we will want for the potential course creation 745 // as they are light. don't get membership -- potentially a lot of data. 746 $ldap_fields_wanted = array('dn', $this->get_config('course_idnumber')); 747 $fullname = $this->get_config('course_fullname'); 748 $shortname = $this->get_config('course_shortname'); 749 $summary = $this->get_config('course_summary'); 750 if (isset($fullname)) { 751 array_push($ldap_fields_wanted, $fullname); 752 } 753 if (isset($shortname)) { 754 array_push($ldap_fields_wanted, $shortname); 755 } 756 if (isset($summary)) { 757 array_push($ldap_fields_wanted, $summary); 758 } 759 760 // Define the search pattern 761 if (empty($ldap_search_pattern)) { 762 $ldap_search_pattern = '('.$this->get_config('memberattribute_role'.$role->id).'='.ldap_filter_addslashes($extmemberuid).')'; 763 } else { 764 $ldap_search_pattern = '(|' . $ldap_search_pattern . 765 '('.$this->get_config('memberattribute_role'.$role->id).'='.ldap_filter_addslashes($extmemberuid).')' . 766 ')'; 767 } 768 $ldap_search_pattern='(&'.$this->get_config('objectclass').$ldap_search_pattern.')'; 769 770 // Get all contexts and look for first matching user 771 $ldap_contexts = explode(';', $ldap_contexts); 772 $ldap_pagedresults = ldap_paged_results_supported($this->get_config('ldap_version'), $this->ldapconnection); 773 foreach ($ldap_contexts as $context) { 774 $context = trim($context); 775 if (empty($context)) { 776 continue; 777 } 778 779 $ldap_cookie = ''; 780 $servercontrols = array(); 781 $flat_records = array(); 782 do { 783 if ($ldap_pagedresults) { 784 $servercontrols = array(array( 785 'oid' => LDAP_CONTROL_PAGEDRESULTS, 'value' => array( 786 'size' => $this->config->pagesize, 'cookie' => $ldap_cookie))); 787 } 788 789 if ($this->get_config('course_search_sub')) { 790 // Use ldap_search to find first user from subtree 791 $ldap_result = @ldap_search($this->ldapconnection, $context, 792 $ldap_search_pattern, $ldap_fields_wanted, 793 0, -1, -1, LDAP_DEREF_NEVER, $servercontrols); 794 } else { 795 // Search only in this context 796 $ldap_result = @ldap_list($this->ldapconnection, $context, 797 $ldap_search_pattern, $ldap_fields_wanted, 798 0, -1, -1, LDAP_DEREF_NEVER, $servercontrols); 799 } 800 801 if (!$ldap_result) { 802 continue; 803 } 804 805 if ($ldap_pagedresults) { 806 // Get next server cookie to know if we'll need to continue searching. 807 $ldap_cookie = ''; 808 // Get next cookie from controls. 809 ldap_parse_result($this->ldapconnection, $ldap_result, $errcode, $matcheddn, 810 $errmsg, $referrals, $controls); 811 if (isset($controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'])) { 812 $ldap_cookie = $controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie']; 813 } 814 } 815 816 // Check and push results. ldap_get_entries() already 817 // lowercases the attribute index, so there's no need to 818 // use array_change_key_case() later. 819 $records = ldap_get_entries($this->ldapconnection, $ldap_result); 820 821 // LDAP libraries return an odd array, really. Fix it. 822 for ($c = 0; $c < $records['count']; $c++) { 823 array_push($flat_records, $records[$c]); 824 } 825 // Free some mem 826 unset($records); 827 } while ($ldap_pagedresults && !empty($ldap_cookie)); 828 829 // If LDAP paged results were used, the current connection must be completely 830 // closed and a new one created, to work without paged results from here on. 831 if ($ldap_pagedresults) { 832 $this->ldap_close(); 833 $this->ldap_connect(); 834 } 835 836 if (count($flat_records)) { 837 $courses = array_merge($courses, $flat_records); 838 } 839 } 840 841 return $courses; 842 } 843 844 /** 845 * Search specified contexts for the specified userid and return the 846 * user dn like: cn=username,ou=suborg,o=org. It's actually a wrapper 847 * around ldap_find_userdn(). 848 * 849 * @param string $userid the userid to search for (in external LDAP encoding, no magic quotes). 850 * @return mixed the user dn or false 851 */ 852 protected function ldap_find_userdn($userid) { 853 global $CFG; 854 require_once($CFG->libdir.'/ldaplib.php'); 855 856 $ldap_contexts = explode(';', $this->get_config('user_contexts')); 857 858 return ldap_find_userdn($this->ldapconnection, $userid, $ldap_contexts, 859 $this->userobjectclass, 860 $this->get_config('idnumber_attribute'), $this->get_config('user_search_sub')); 861 } 862 863 /** 864 * Find the groups a given distinguished name belongs to, both directly 865 * and indirectly via nested groups membership. 866 * 867 * @param string $memberdn distinguished name to search 868 * @return array with member groups' distinguished names (can be emtpy) 869 */ 870 protected function ldap_find_user_groups($memberdn) { 871 $groups = array(); 872 873 $this->ldap_find_user_groups_recursively($memberdn, $groups); 874 return $groups; 875 } 876 877 /** 878 * Recursively process the groups the given member distinguished name 879 * belongs to, adding them to the already processed groups array. 880 * 881 * @param string $memberdn distinguished name to search 882 * @param array reference &$membergroups array with already found 883 * groups, where we'll put the newly found 884 * groups. 885 */ 886 protected function ldap_find_user_groups_recursively($memberdn, &$membergroups) { 887 $result = @ldap_read($this->ldapconnection, $memberdn, '(objectClass=*)', array($this->get_config('group_memberofattribute'))); 888 if (!$result) { 889 return; 890 } 891 892 if ($entry = ldap_first_entry($this->ldapconnection, $result)) { 893 do { 894 $attributes = ldap_get_attributes($this->ldapconnection, $entry); 895 for ($j = 0; $j < $attributes['count']; $j++) { 896 $groups = ldap_get_values_len($this->ldapconnection, $entry, $attributes[$j]); 897 foreach ($groups as $key => $group) { 898 if ($key === 'count') { // Skip the entries count 899 continue; 900 } 901 if(!in_array($group, $membergroups)) { 902 // Only push and recurse if we haven't 'seen' this group before 903 // to prevent loops (MS Active Directory allows them!!). 904 array_push($membergroups, $group); 905 $this->ldap_find_user_groups_recursively($group, $membergroups); 906 } 907 } 908 } 909 } 910 while ($entry = ldap_next_entry($this->ldapconnection, $entry)); 911 } 912 } 913 914 /** 915 * Given a group name (either a RDN or a DN), get the list of users 916 * belonging to that group. If the group has nested groups, expand all 917 * the intermediate groups and return the full list of users that 918 * directly or indirectly belong to the group. 919 * 920 * @param string $group the group name to search 921 * @param string $memberattibute the attribute that holds the members of the group 922 * @return array the list of users belonging to the group. If $group 923 * is not actually a group, returns array($group). 924 */ 925 protected function ldap_explode_group($group, $memberattribute) { 926 switch ($this->get_config('user_type')) { 927 case 'ad': 928 // $group is already the distinguished name to search. 929 $dn = $group; 930 931 $result = ldap_read($this->ldapconnection, $dn, '(objectClass=*)', array('objectClass')); 932 $entry = ldap_first_entry($this->ldapconnection, $result); 933 $objectclass = ldap_get_values($this->ldapconnection, $entry, 'objectClass'); 934 935 if (!in_array('group', $objectclass)) { 936 // Not a group, so return immediately. 937 return array($group); 938 } 939 940 $result = ldap_read($this->ldapconnection, $dn, '(objectClass=*)', array($memberattribute)); 941 $entry = ldap_first_entry($this->ldapconnection, $result); 942 $members = @ldap_get_values($this->ldapconnection, $entry, $memberattribute); // Can be empty and throws a warning 943 if ($members['count'] == 0) { 944 // There are no members in this group, return nothing. 945 return array(); 946 } 947 unset($members['count']); 948 949 $users = array(); 950 foreach ($members as $member) { 951 $group_members = $this->ldap_explode_group($member, $memberattribute); 952 $users = array_merge($users, $group_members); 953 } 954 955 return ($users); 956 break; 957 default: 958 error_log($this->errorlogtag.get_string('explodegroupusertypenotsupported', 'enrol_ldap', 959 $this->get_config('user_type_name'))); 960 961 return array($group); 962 } 963 } 964 965 /** 966 * Will create the moodle course from the template 967 * course_ext is an array as obtained from ldap -- flattened somewhat 968 * 969 * @param array $course_ext 970 * @param progress_trace $trace 971 * @return mixed false on error, id for the newly created course otherwise. 972 */ 973 function create_course($course_ext, progress_trace $trace) { 974 global $CFG, $DB; 975 976 require_once("$CFG->dirroot/course/lib.php"); 977 978 // Override defaults with template course 979 $template = false; 980 if ($this->get_config('template')) { 981 if ($template = $DB->get_record('course', array('shortname'=>$this->get_config('template')))) { 982 $template = fullclone(course_get_format($template)->get_course()); 983 unset($template->id); // So we are clear to reinsert the record 984 unset($template->fullname); 985 unset($template->shortname); 986 unset($template->idnumber); 987 } 988 } 989 if (!$template) { 990 $courseconfig = get_config('moodlecourse'); 991 $template = new stdClass(); 992 $template->summary = ''; 993 $template->summaryformat = FORMAT_HTML; 994 $template->format = $courseconfig->format; 995 $template->newsitems = $courseconfig->newsitems; 996 $template->showgrades = $courseconfig->showgrades; 997 $template->showreports = $courseconfig->showreports; 998 $template->maxbytes = $courseconfig->maxbytes; 999 $template->groupmode = $courseconfig->groupmode; 1000 $template->groupmodeforce = $courseconfig->groupmodeforce; 1001 $template->visible = $courseconfig->visible; 1002 $template->lang = $courseconfig->lang; 1003 $template->enablecompletion = $courseconfig->enablecompletion; 1004 } 1005 $course = $template; 1006 1007 $course->category = $this->get_config('category'); 1008 if (!$DB->record_exists('course_categories', array('id'=>$this->get_config('category')))) { 1009 $categories = $DB->get_records('course_categories', array(), 'sortorder', 'id', 0, 1); 1010 $first = reset($categories); 1011 $course->category = $first->id; 1012 } 1013 1014 // Override with required ext data 1015 $course->idnumber = $course_ext[$this->get_config('course_idnumber')][0]; 1016 $course->fullname = $course_ext[$this->get_config('course_fullname')][0]; 1017 $course->shortname = $course_ext[$this->get_config('course_shortname')][0]; 1018 if (empty($course->idnumber) || empty($course->fullname) || empty($course->shortname)) { 1019 // We are in trouble! 1020 $trace->output(get_string('cannotcreatecourse', 'enrol_ldap').' '.var_export($course, true)); 1021 return false; 1022 } 1023 1024 $summary = $this->get_config('course_summary'); 1025 if (!isset($summary) || empty($course_ext[$summary][0])) { 1026 $course->summary = ''; 1027 } else { 1028 $course->summary = $course_ext[$this->get_config('course_summary')][0]; 1029 } 1030 1031 // Check if the shortname already exists if it does - skip course creation. 1032 if ($DB->record_exists('course', array('shortname' => $course->shortname))) { 1033 $trace->output(get_string('duplicateshortname', 'enrol_ldap', $course)); 1034 return false; 1035 } 1036 1037 $newcourse = create_course($course); 1038 return $newcourse->id; 1039 } 1040 1041 /** 1042 * Will update a moodle course with new values from LDAP 1043 * A field will be updated only if it is marked to be updated 1044 * on sync in plugin settings 1045 * 1046 * @param object $course 1047 * @param array $externalcourse 1048 * @param progress_trace $trace 1049 * @return bool 1050 */ 1051 protected function update_course($course, $externalcourse, progress_trace $trace) { 1052 global $CFG, $DB; 1053 1054 $coursefields = array ('shortname', 'fullname', 'summary'); 1055 static $shouldupdate; 1056 1057 // Initialize $shouldupdate variable. Set to true if one or more fields are marked for update. 1058 if (!isset($shouldupdate)) { 1059 $shouldupdate = false; 1060 foreach ($coursefields as $field) { 1061 $shouldupdate = $shouldupdate || $this->get_config('course_'.$field.'_updateonsync'); 1062 } 1063 } 1064 1065 // If we should not update return immediately. 1066 if (!$shouldupdate) { 1067 return false; 1068 } 1069 1070 require_once("$CFG->dirroot/course/lib.php"); 1071 $courseupdated = false; 1072 $updatedcourse = new stdClass(); 1073 $updatedcourse->id = $course->id; 1074 1075 // Update course fields if necessary. 1076 foreach ($coursefields as $field) { 1077 // If field is marked to be updated on sync && field data was changed update it. 1078 if ($this->get_config('course_'.$field.'_updateonsync') 1079 && isset($externalcourse[$this->get_config('course_'.$field)][0]) 1080 && $course->{$field} != $externalcourse[$this->get_config('course_'.$field)][0]) { 1081 $updatedcourse->{$field} = $externalcourse[$this->get_config('course_'.$field)][0]; 1082 $courseupdated = true; 1083 } 1084 } 1085 1086 if (!$courseupdated) { 1087 $trace->output(get_string('courseupdateskipped', 'enrol_ldap', $course)); 1088 return false; 1089 } 1090 1091 // Do not allow empty fullname or shortname. 1092 if ((isset($updatedcourse->fullname) && empty($updatedcourse->fullname)) 1093 || (isset($updatedcourse->shortname) && empty($updatedcourse->shortname))) { 1094 // We are in trouble! 1095 $trace->output(get_string('cannotupdatecourse', 'enrol_ldap', $course)); 1096 return false; 1097 } 1098 1099 // Check if the shortname already exists if it does - skip course updating. 1100 if (isset($updatedcourse->shortname) 1101 && $DB->record_exists('course', array('shortname' => $updatedcourse->shortname))) { 1102 $trace->output(get_string('cannotupdatecourse_duplicateshortname', 'enrol_ldap', $course)); 1103 return false; 1104 } 1105 1106 // Finally - update course in DB. 1107 update_course($updatedcourse); 1108 $trace->output(get_string('courseupdated', 'enrol_ldap', $course)); 1109 1110 return true; 1111 } 1112 1113 /** 1114 * Automatic enrol sync executed during restore. 1115 * Useful for automatic sync by course->idnumber or course category. 1116 * @param stdClass $course course record 1117 */ 1118 public function restore_sync_course($course) { 1119 // TODO: this can not work because restore always nukes the course->idnumber, do not ask me why (MDL-37312) 1120 // NOTE: for now restore does not do any real logging yet, let's do the same here... 1121 $trace = new error_log_progress_trace(); 1122 $this->sync_enrolments($trace, $course->id); 1123 } 1124 1125 /** 1126 * Restore instance and map settings. 1127 * 1128 * @param restore_enrolments_structure_step $step 1129 * @param stdClass $data 1130 * @param stdClass $course 1131 * @param int $oldid 1132 */ 1133 public function restore_instance(restore_enrolments_structure_step $step, stdClass $data, $course, $oldid) { 1134 global $DB; 1135 // There is only 1 ldap enrol instance per course. 1136 if ($instances = $DB->get_records('enrol', array('courseid'=>$data->courseid, 'enrol'=>'ldap'), 'id')) { 1137 $instance = reset($instances); 1138 $instanceid = $instance->id; 1139 } else { 1140 $instanceid = $this->add_instance($course, (array)$data); 1141 } 1142 $step->set_mapping('enrol', $oldid, $instanceid); 1143 } 1144 1145 /** 1146 * Restore user enrolment. 1147 * 1148 * @param restore_enrolments_structure_step $step 1149 * @param stdClass $data 1150 * @param stdClass $instance 1151 * @param int $oldinstancestatus 1152 * @param int $userid 1153 */ 1154 public function restore_user_enrolment(restore_enrolments_structure_step $step, $data, $instance, $userid, $oldinstancestatus) { 1155 global $DB; 1156 1157 if ($this->get_config('unenrolaction') == ENROL_EXT_REMOVED_UNENROL) { 1158 // Enrolments were already synchronised in restore_instance(), we do not want any suspended leftovers. 1159 1160 } else if ($this->get_config('unenrolaction') == ENROL_EXT_REMOVED_KEEP) { 1161 if (!$DB->record_exists('user_enrolments', array('enrolid'=>$instance->id, 'userid'=>$userid))) { 1162 $this->enrol_user($instance, $userid, null, 0, 0, $data->status); 1163 } 1164 1165 } else { 1166 if (!$DB->record_exists('user_enrolments', array('enrolid'=>$instance->id, 'userid'=>$userid))) { 1167 $this->enrol_user($instance, $userid, null, 0, 0, ENROL_USER_SUSPENDED); 1168 } 1169 } 1170 } 1171 1172 /** 1173 * Restore role assignment. 1174 * 1175 * @param stdClass $instance 1176 * @param int $roleid 1177 * @param int $userid 1178 * @param int $contextid 1179 */ 1180 public function restore_role_assignment($instance, $roleid, $userid, $contextid) { 1181 global $DB; 1182 1183 if ($this->get_config('unenrolaction') == ENROL_EXT_REMOVED_UNENROL or $this->get_config('unenrolaction') == ENROL_EXT_REMOVED_SUSPENDNOROLES) { 1184 // Skip any roles restore, they should be already synced automatically. 1185 return; 1186 } 1187 1188 // Just restore every role. 1189 if ($DB->record_exists('user_enrolments', array('enrolid'=>$instance->id, 'userid'=>$userid))) { 1190 role_assign($roleid, $userid, $contextid, 'enrol_'.$instance->enrol, $instance->id); 1191 } 1192 } 1193 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body