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