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